diff --git a/docs/05-business-flows.md b/docs/05-business-flows.md index 3cd6a7cb..4ee551f3 100644 --- a/docs/05-business-flows.md +++ b/docs/05-business-flows.md @@ -82,11 +82,21 @@ | namespace MEMBER | 可读 | 可读 | 不可读 | 不可读 | 仅自己是 owner 时可读 | | skill owner | 可读 | 可读 | 可读 | 可读 | 可读 | | namespace ADMIN / OWNER | 可读 | 可读 | 可读 | 可读 | 不可读,除非本人也是 skill owner | -| SKILL_ADMIN / SUPER_ADMIN(仅平台角色) | 与普通登录用户一致;普通读路径不会因为平台角色自动穿透 private / hidden / unpublished | +| SKILL_ADMIN(仅平台角色) | 与普通登录用户一致;普通读路径不会因为平台角色自动穿透 private / hidden / unpublished | +| SUPER_ADMIN(仅平台角色) | 可通过平台 namespace 读取语义读取已发布 `NAMESPACE_ONLY`;不可读 `PRIVATE`、`hidden=true`、未发布版本,除非同时满足 owner 或 namespace 管理者条件 | 补充: - `hidden=true` 时,可读权限会收敛为“skill owner 或 namespace `ADMIN` / `OWNER`” - `visibility=PUBLIC` 也不意味着未发布 skill 可见;当 `latest_version_id` 为空时,只有 owner 能读 +- `SUPER_ADMIN` 的 namespace 读取语义用于平台可见性与详情巡检,可覆盖 archived namespace 的读取入口;它不会把非成员自动提升为 namespace 成员,也不会扩大成员管理、团队审核任务或 namespace 写权限 + +#### 1.3.1.1 Namespace 读取与会话角色刷新 + +- `GET /api/v1/me/namespaces` 保持历史数组响应;`GET /api/v1/me/namespaces/page` 提供分页响应 `{ items, total, page, size }`。 +- namespace 成员只看到自己所属 namespace;`SUPER_ADMIN` 可看到全部 `ACTIVE`、`FROZEN`、`ARCHIVED` namespace。 +- 非成员 `SUPER_ADMIN` 在 namespace 卡片与详情中的 `currentUserRole` 为空,成员管理、团队审核入口、冻结/归档/删除等写能力仍按 namespace membership 或专门平台治理权限判断。 +- 已登录 session 在受保护请求入口会用持久化平台角色刷新权限快照;授予或撤销 `SUPER_ADMIN` 后,namespace 读链路不依赖用户先访问 `/api/v1/auth/me` 才生效。 +- `/me/namespaces/page` 的分页 query 为 `page`、`size`、`sort`;`size` 后端上限为 100,`sort` 仅支持 `slug,asc` / `slug,desc`,默认 `slug,asc`。 #### 1.3.2 Version 状态读取 diff --git a/docs/06-api-design.md b/docs/06-api-design.md index 56cb5541..ad5871be 100644 --- a/docs/06-api-design.md +++ b/docs/06-api-design.md @@ -87,10 +87,11 @@ Public API 的可见性规则: - `PUBLIC` 技能:若存在已发布版本,则已登录用户可访问;匿名访问仍受下载/resolve 端点的 namespace 类型限制 -- `NAMESPACE_ONLY` 技能:仅该命名空间成员可访问(需登录) +- `NAMESPACE_ONLY` 技能:该命名空间成员可访问(需登录);非成员 `SUPER_ADMIN` 仅在平台 namespace 读取语义下可读取已发布版本 - `PRIVATE` 技能:owner 本人 + 该 namespace 的 ADMIN 以上可访问(需登录) - 若 `latest_version_id = null`,即使 `visibility=PUBLIC`,skill 也不会对外公开,只有 owner 可访问 - `hidden=true` 时,普通访客不可访问;仅 owner 或该 namespace 的 `ADMIN` / `OWNER` 可访问 +- `SUPER_ADMIN` 的平台 namespace 读取语义不会穿透 `PRIVATE`、`hidden=true` 或未发布版本,也不会赋予非成员 namespace 写权限、成员管理权限或团队审核任务处理权限 `GET /api/v1/skills/{namespace}/{slug}/versions/{version}` 的 `data` 字段除版本基础信息外,还必须包含: @@ -330,10 +331,14 @@ Admin API 按最小权限拆分,不再统一要求 SUPER_ADMIN: |------|------|------| | GET | `/api/v1/admin/audit-logs` | 审计日志查询 | -## 7.7 Namespace 管理 API(需命名空间 OWNER 或 ADMIN) +## 7.7 Namespace API | 方法 | 路径 | 说明 | |------|------|------| +| GET | `/api/v1/me/namespaces` | 当前用户可见 namespace,保持历史数组响应;成员看到所属 namespace,`SUPER_ADMIN` 可见全部 namespace | +| GET | `/api/v1/me/namespaces/page` | 当前用户可见 namespace 分页响应 `{ items, total, page, size }`;query 支持 `page`、`size`、`sort`、`status`、`type`、`q`、`slug`、`roles` | +| GET | `/api/v1/namespaces` | namespace 列表;普通用户按 membership 返回 active namespace,`SUPER_ADMIN` 可读 active namespace | +| GET | `/api/v1/namespaces/{slug}` | namespace 详情;成员可读所属 namespace,`SUPER_ADMIN` 可读非成员 namespace,包括 archived namespace | | POST | `/api/v1/namespaces` | 创建命名空间 | | PUT | `/api/v1/namespaces/{slug}` | 更新命名空间信息 | | GET | `/api/v1/namespaces/{slug}/members` | 成员列表 | @@ -345,6 +350,8 @@ Admin API 按最小权限拆分,不再统一要求 SUPER_ADMIN: | POST | `/api/v1/reviews/{id}/reject` | 空间管理员审核拒绝 | | POST | `/api/v1/promotions` | 申请提升到全局 | +`/api/web` 暴露同名 Web 端点,契约与 `/api/v1` 一致。`/me/namespaces/page` 的 `page` 为 0 基页码,`size` 默认 20 且后端上限 100;`sort` 仅支持 `slug,asc` / `slug,desc`,默认 `slug,asc`。非成员 `SUPER_ADMIN` 读取 namespace 时 `currentUserRole` 为空,成员管理、团队审核入口和写操作仍需 namespace `OWNER` / `ADMIN` membership 或对应平台治理权限。 + ## 7.8 `latest` 语义说明 `latest` 自动跟随最新已发布版本,不可手动移动。 diff --git a/scripts/namespace-smoke-test.sh b/scripts/namespace-smoke-test.sh index ae2b7b59..e9bf8c6c 100755 --- a/scripts/namespace-smoke-test.sh +++ b/scripts/namespace-smoke-test.sh @@ -8,8 +8,20 @@ FAIL=0 USER_COOKIE="$(mktemp)" ADMIN_COOKIE="$(mktemp)" SLUG="nsmoke$(date +%s)" +USER_ROLE_GRANTED=0 cleanup() { + if [[ "$USER_ROLE_GRANTED" -eq 1 ]] && [[ -f "$ADMIN_COOKIE" ]]; then + local admin_csrf + admin_csrf="$(csrf_token "$ADMIN_COOKIE" 2>/dev/null || true)" + if [[ -n "$admin_csrf" ]]; then + curl -sS "${ADMIN_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $admin_csrf" \ + -H "Content-Type: application/json" \ + -X PUT "$BASE_URL/api/v1/admin/users/local-user/role" \ + -d '{"role":"USER"}' >/dev/null || true + fi + fi rm -f "$USER_COOKIE" "$ADMIN_COOKIE" } @@ -91,17 +103,17 @@ if [[ -z "$USER_CSRF" || -z "$ADMIN_CSRF" ]]; then exit 1 fi -CREATE_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" \ - -H "X-XSRF-TOKEN: $USER_CSRF" \ +CREATE_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $ADMIN_CSRF" \ -H "Content-Type: application/json" \ -X POST "$BASE_URL/api/web/namespaces" \ -d "{\"slug\":\"$SLUG\",\"displayName\":\"Namespace Smoke $SLUG\",\"description\":\"namespace workflow smoke test\"}")" -assert_code "Owner can create namespace" "$CREATE_RESPONSE" "0" +assert_code "SUPER_ADMIN can create namespace" "$CREATE_RESPONSE" "0" NAMESPACE_ID="$(json_field "$CREATE_RESPONSE" "data.id")" -MINE_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" "$BASE_URL/api/web/me/namespaces")" -assert_code "Owner can list my namespaces" "$MINE_RESPONSE" "0" -if JSON_INPUT="$MINE_RESPONSE" python3 - "$SLUG" <<'PY' +ADMIN_MINE_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" "$BASE_URL/api/web/me/namespaces")" +assert_code "Owner can list my namespaces" "$ADMIN_MINE_RESPONSE" "0" +if JSON_INPUT="$ADMIN_MINE_RESPONSE" python3 - "$SLUG" <<'PY' import json import os import sys @@ -122,9 +134,9 @@ else fail "Created namespace should appear in owner namespace list with OWNER role" fi -ADMIN_MINE_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" "$BASE_URL/api/web/me/namespaces")" -assert_code "Other user can list my namespaces" "$ADMIN_MINE_RESPONSE" "0" -if JSON_INPUT="$ADMIN_MINE_RESPONSE" python3 - "$SLUG" <<'PY' +USER_MINE_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" "$BASE_URL/api/web/me/namespaces")" +assert_code "Regular user can list my namespaces" "$USER_MINE_RESPONSE" "0" +if JSON_INPUT="$USER_MINE_RESPONSE" python3 - "$SLUG" <<'PY' import json import os import sys @@ -134,17 +146,73 @@ items = data["data"] raise SystemExit(0 if all(item["slug"] != slug for item in items) else 1) PY then - pass "Namespace is not visible to unrelated users in my namespaces" + pass "Regular user cannot see non-member team namespace" else - fail "Unrelated user should not see team namespace in my namespaces" + fail "Regular user should not see non-member team namespace" fi -FREEZE_FORBIDDEN_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" \ +GRANT_SUPER_ADMIN_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" \ -H "X-XSRF-TOKEN: $ADMIN_CSRF" \ - -X POST "$BASE_URL/api/web/namespaces/$SLUG/freeze")" -assert_code "Unrelated user cannot freeze namespace" "$FREEZE_FORBIDDEN_RESPONSE" "403" + -H "Content-Type: application/json" \ + -X PUT "$BASE_URL/api/v1/admin/users/local-user/role" \ + -d '{"role":"SUPER_ADMIN"}')" +assert_code "SUPER_ADMIN can grant platform SUPER_ADMIN role" "$GRANT_SUPER_ADMIN_RESPONSE" "0" +USER_ROLE_GRANTED=1 -CANDIDATES_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" "$BASE_URL/api/web/namespaces/$SLUG/member-candidates?search=local")" +USER_SUPER_ADMIN_MINE_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" "$BASE_URL/api/web/me/namespaces")" +assert_code "Granted SUPER_ADMIN session can list my namespaces" "$USER_SUPER_ADMIN_MINE_RESPONSE" "0" +if JSON_INPUT="$USER_SUPER_ADMIN_MINE_RESPONSE" python3 - "$SLUG" <<'PY' +import json +import os +import sys +slug = sys.argv[1] +data = json.loads(os.environ["JSON_INPUT"]) +items = data["data"] +match = next((item for item in items if item["slug"] == slug), None) +if not match: + raise SystemExit(1) +if match.get("currentUserRole") is not None: + raise SystemExit(2) +if match["status"] != "ACTIVE": + raise SystemExit(3) +PY +then + pass "Granted SUPER_ADMIN can see namespace without namespace membership" +else + fail "Granted SUPER_ADMIN should see team namespace without namespace role" +fi + +FREEZE_FORBIDDEN_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $USER_CSRF" \ + -X POST "$BASE_URL/api/web/namespaces/$SLUG/freeze")" +assert_code "Non-member SUPER_ADMIN cannot freeze namespace" "$FREEZE_FORBIDDEN_RESPONSE" "403" + +REVOKE_SUPER_ADMIN_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $ADMIN_CSRF" \ + -H "Content-Type: application/json" \ + -X PUT "$BASE_URL/api/v1/admin/users/local-user/role" \ + -d '{"role":"USER"}')" +assert_code "SUPER_ADMIN can revoke platform role to USER" "$REVOKE_SUPER_ADMIN_RESPONSE" "0" +USER_ROLE_GRANTED=0 + +USER_AFTER_REVOKE_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" "$BASE_URL/api/web/me/namespaces")" +assert_code "Revoked session can list my namespaces" "$USER_AFTER_REVOKE_RESPONSE" "0" +if JSON_INPUT="$USER_AFTER_REVOKE_RESPONSE" python3 - "$SLUG" <<'PY' +import json +import os +import sys +slug = sys.argv[1] +data = json.loads(os.environ["JSON_INPUT"]) +items = data["data"] +raise SystemExit(0 if all(item["slug"] != slug for item in items) else 1) +PY +then + pass "Revoked SUPER_ADMIN session no longer sees non-member namespace" +else + fail "Revoked SUPER_ADMIN session should lose non-member namespace visibility" +fi + +CANDIDATES_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" "$BASE_URL/api/web/namespaces/$SLUG/member-candidates?search=local")" assert_code "Owner can search namespace member candidates" "$CANDIDATES_RESPONSE" "0" if JSON_INPUT="$CANDIDATES_RESPONSE" python3 - <<'PY' import json @@ -152,22 +220,22 @@ import os import sys data = json.loads(os.environ["JSON_INPUT"]) ids = {item["userId"] for item in data["data"]} -raise SystemExit(0 if "local-admin" in ids else 1) +raise SystemExit(0 if "local-user" in ids else 1) PY then - pass "Candidate search returns local-admin" + pass "Candidate search returns local-user" else - fail "Candidate search should include local-admin" + fail "Candidate search should include local-user" fi -ADD_MEMBER_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" \ - -H "X-XSRF-TOKEN: $USER_CSRF" \ +ADD_MEMBER_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $ADMIN_CSRF" \ -H "Content-Type: application/json" \ -X POST "$BASE_URL/api/web/namespaces/$SLUG/members" \ - -d '{"userId":"local-admin","role":"MEMBER"}')" + -d '{"userId":"local-user","role":"MEMBER"}')" assert_code "Owner can add namespace members" "$ADD_MEMBER_RESPONSE" "0" -MEMBERS_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" "$BASE_URL/api/web/namespaces/$SLUG/members")" +MEMBERS_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" "$BASE_URL/api/web/namespaces/$SLUG/members")" assert_code "Owner can list namespace members" "$MEMBERS_RESPONSE" "0" if JSON_INPUT="$MEMBERS_RESPONSE" python3 - <<'PY' import json @@ -184,18 +252,18 @@ else fail "Member list should contain owner and invited user" fi -REVIEWS_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" "$BASE_URL/api/web/reviews?status=PENDING&namespaceId=$NAMESPACE_ID")" +REVIEWS_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" "$BASE_URL/api/web/reviews?status=PENDING&namespaceId=$NAMESPACE_ID")" assert_code "Owner can open namespace review list" "$REVIEWS_RESPONSE" "0" -PROMOTE_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" \ - -H "X-XSRF-TOKEN: $USER_CSRF" \ +PROMOTE_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $ADMIN_CSRF" \ -H "Content-Type: application/json" \ - -X PUT "$BASE_URL/api/web/namespaces/$SLUG/members/local-admin/role" \ + -X PUT "$BASE_URL/api/web/namespaces/$SLUG/members/local-user/role" \ -d '{"role":"ADMIN"}')" assert_code "Owner can promote member to admin" "$PROMOTE_RESPONSE" "0" -ADMIN_FREEZE_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" \ - -H "X-XSRF-TOKEN: $ADMIN_CSRF" \ +ADMIN_FREEZE_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $USER_CSRF" \ -X POST "$BASE_URL/api/web/namespaces/$SLUG/freeze")" assert_code "Namespace admin can freeze namespace" "$ADMIN_FREEZE_RESPONSE" "0" if [[ "$(json_field "$ADMIN_FREEZE_RESPONSE" "data.status")" == "FROZEN" ]]; then @@ -204,27 +272,27 @@ else fail "Freeze should set namespace status to FROZEN" fi -ADD_WHILE_FROZEN_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" \ - -H "X-XSRF-TOKEN: $USER_CSRF" \ +ADD_WHILE_FROZEN_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $ADMIN_CSRF" \ -H "Content-Type: application/json" \ -X POST "$BASE_URL/api/web/namespaces/$SLUG/members" \ -d '{"userId":"local-user","role":"MEMBER"}')" assert_code "Frozen namespace rejects member mutation" "$ADD_WHILE_FROZEN_RESPONSE" "400" -ADMIN_UNFREEZE_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" \ - -H "X-XSRF-TOKEN: $ADMIN_CSRF" \ +ADMIN_UNFREEZE_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $USER_CSRF" \ -X POST "$BASE_URL/api/web/namespaces/$SLUG/unfreeze")" assert_code "Namespace admin can unfreeze namespace" "$ADMIN_UNFREEZE_RESPONSE" "0" -ADMIN_ARCHIVE_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" \ - -H "X-XSRF-TOKEN: $ADMIN_CSRF" \ +ADMIN_ARCHIVE_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $USER_CSRF" \ -H "Content-Type: application/json" \ -X POST "$BASE_URL/api/web/namespaces/$SLUG/archive" \ -d '{"reason":"smoke"}')" assert_code "Namespace admin cannot archive namespace" "$ADMIN_ARCHIVE_RESPONSE" "403" -OWNER_ARCHIVE_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" \ - -H "X-XSRF-TOKEN: $USER_CSRF" \ +OWNER_ARCHIVE_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $ADMIN_CSRF" \ -H "Content-Type: application/json" \ -X POST "$BASE_URL/api/web/namespaces/$SLUG/archive" \ -d '{"reason":"smoke"}')" @@ -235,8 +303,8 @@ else fail "Archive should set namespace status to ARCHIVED" fi -OWNER_RESTORE_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" \ - -H "X-XSRF-TOKEN: $USER_CSRF" \ +OWNER_RESTORE_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $ADMIN_CSRF" \ -X POST "$BASE_URL/api/web/namespaces/$SLUG/restore")" assert_code "Owner can restore archived namespace" "$OWNER_RESTORE_RESPONSE" "0" if [[ "$(json_field "$OWNER_RESTORE_RESPONSE" "data.status")" == "ACTIVE" ]]; then @@ -245,9 +313,9 @@ else fail "Restore should set namespace status back to ACTIVE" fi -REMOVE_MEMBER_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" \ - -H "X-XSRF-TOKEN: $USER_CSRF" \ - -X DELETE "$BASE_URL/api/web/namespaces/$SLUG/members/local-admin")" +REMOVE_MEMBER_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $ADMIN_CSRF" \ + -X DELETE "$BASE_URL/api/web/namespaces/$SLUG/members/local-user")" assert_code "Owner can remove namespace admin" "$REMOVE_MEMBER_RESPONSE" "0" echo diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java index c0c34567..77ea3e80 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java @@ -3,6 +3,8 @@ package com.iflytek.skillhub.controller.portal; import com.iflytek.skillhub.controller.BaseApiController; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.namespace.NamespaceStatus; +import com.iflytek.skillhub.domain.namespace.NamespaceType; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.BatchMemberRequest; @@ -23,12 +25,17 @@ import com.iflytek.skillhub.service.GovernanceWorkflowAppService; import com.iflytek.skillhub.service.NamespacePortalCommandAppService; import com.iflytek.skillhub.service.NamespacePortalQueryAppService; import com.iflytek.skillhub.service.NamespaceMemberCandidateService; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Set; @@ -61,23 +68,56 @@ public class NamespaceController extends BaseApiController { @GetMapping("/namespaces") public ApiResponse> listNamespaces( Pageable pageable, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { - return ok("response.success.read", namespacePortalQueryAppService.listNamespaces(pageable, userNsRoles)); + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestAttribute(value = "platformRoles", required = false) Set platformRoles) { + return ok("response.success.read", + namespacePortalQueryAppService.listNamespaces(pageable, userNsRoles, normalizePlatformRoles(platformRoles))); } @GetMapping("/me/namespaces") public ApiResponse> listMyNamespaces( @RequestAttribute("userId") String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { - return ok("response.success.read", namespacePortalQueryAppService.listMyNamespaces(userNsRoles)); + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestAttribute(value = "platformRoles", required = false) Set platformRoles) { + return ok("response.success.read", + namespacePortalQueryAppService.listMyNamespaces(userNsRoles, normalizePlatformRoles(platformRoles))); + } + + @GetMapping("/me/namespaces/page") + public ApiResponse> listMyNamespacesPage( + @Parameter(description = "Zero-based page index.", schema = @Schema(type = "integer", format = "int32", defaultValue = "0", minimum = "0")) + @RequestParam(defaultValue = "0") int page, + @Parameter(description = "Page size. Values above the namespace list limit are bounded by the backend.", schema = @Schema(type = "integer", format = "int32", defaultValue = "20", minimum = "1")) + @RequestParam(defaultValue = "20") int size, + @Parameter(description = "Sort criteria in property,direction form. Only slug sorting is honored; defaults to slug,asc.", example = "slug,asc") + @RequestParam(required = false) List sort, + @RequestParam(required = false) NamespaceStatus status, + @RequestParam(required = false) NamespaceType type, + @RequestParam(required = false) String q, + @RequestParam(required = false) String slug, + @RequestParam(required = false) Set roles, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestAttribute(value = "platformRoles", required = false) Set platformRoles) { + return ok("response.success.read", + namespacePortalQueryAppService.listMyNamespaces( + myNamespacesPageable(page, size, sort), + userNsRoles, + normalizePlatformRoles(platformRoles), + status, + type, + q, + slug, + roles)); } @GetMapping("/namespaces/{slug}") public ApiResponse getNamespace(@PathVariable String slug, @RequestAttribute("userId") String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestAttribute(value = "platformRoles", required = false) Set platformRoles) { return ok("response.success.read", - namespacePortalQueryAppService.getNamespace(slug, userId, userNsRoles)); + namespacePortalQueryAppService.getNamespace(slug, userId, userNsRoles, normalizePlatformRoles(platformRoles))); } @PostMapping("/namespaces") @@ -165,6 +205,48 @@ public class NamespaceController extends BaseApiController { namespacePortalQueryAppService.listMembers(slug, pageable, userId, platformRoles)); } + private Set normalizePlatformRoles(Set platformRoles) { + return platformRoles != null + ? platformRoles + : Set.of(); + } + + private Pageable myNamespacesPageable(int page, int size, List sort) { + return PageRequest.of( + Math.max(page, 0), + Math.max(size, 1), + myNamespacesSort(sort) + ); + } + + private Sort myNamespacesSort(List sort) { + if (sort == null || sort.isEmpty()) { + return Sort.unsorted(); + } + List orders = new ArrayList<>(); + for (String rawSort : sort) { + Sort.Order order = slugOrder(rawSort); + if (order != null) { + orders.add(order); + } + } + return orders.isEmpty() ? Sort.unsorted() : Sort.by(orders); + } + + private Sort.Order slugOrder(String rawSort) { + if (rawSort == null || rawSort.isBlank()) { + return null; + } + String[] tokens = rawSort.split(","); + if (!"slug".equals(tokens[0].trim())) { + return null; + } + Sort.Direction direction = tokens.length > 1 + ? Sort.Direction.fromOptionalString(tokens[1].trim()).orElse(Sort.Direction.ASC) + : Sort.Direction.ASC; + return new Sort.Order(direction, "slug"); + } + @GetMapping("/namespaces/{slug}/member-candidates") public ApiResponse> searchMemberCandidates( @PathVariable String slug, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java index 7c0df9c9..ac2a02d7 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java @@ -37,6 +37,7 @@ import java.io.InputStream; import java.net.URI; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; /** @@ -74,10 +75,15 @@ public class SkillController extends BaseApiController { @PathVariable String namespace, @PathVariable String slug, @RequestAttribute(value = "userId", required = false) String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestAttribute(value = "platformRoles", required = false) Set platformRoles) { SkillQueryService.SkillDetailDTO detail = skillQueryService.getSkillDetail( - namespace, slug, userId, userNsRoles != null ? userNsRoles : Map.of()); + namespace, + slug, + userId, + userNsRoles != null ? userNsRoles : Map.of(), + normalizePlatformRoles(platformRoles)); SkillDetailResponse response = new SkillDetailResponse( detail.id(), @@ -121,14 +127,16 @@ public class SkillController extends BaseApiController { @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, @RequestAttribute(value = "userId", required = false) String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestAttribute(value = "platformRoles", required = false) Set platformRoles) { Page versions = skillQueryService.listVersions( namespace, slug, userId, userNsRoles != null ? userNsRoles : Map.of(), - PageRequest.of(page, size)); + PageRequest.of(page, size), + normalizePlatformRoles(platformRoles)); PageResponse response = PageResponse.from(versions.map(v -> new SkillVersionResponse( v.getId(), @@ -154,14 +162,16 @@ public class SkillController extends BaseApiController { @PathVariable String slug, @PathVariable String version, @RequestAttribute(value = "userId", required = false) String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestAttribute(value = "platformRoles", required = false) Set platformRoles) { SkillQueryService.SkillVersionDetailDTO detail = skillQueryService.getVersionDetail( namespace, slug, version, userId, - userNsRoles != null ? userNsRoles : Map.of() + userNsRoles != null ? userNsRoles : Map.of(), + normalizePlatformRoles(platformRoles) ); SkillVersionDetailResponse response = new SkillVersionDetailResponse( @@ -185,7 +195,8 @@ public class SkillController extends BaseApiController { @RequestParam("from") String from, @RequestParam("to") String to, @RequestAttribute(value = "userId", required = false) String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestAttribute(value = "platformRoles", required = false) Set platformRoles) { SkillQueryService.SkillVersionCompareDTO compare = skillQueryService.compareVersions( namespace, @@ -193,7 +204,8 @@ public class SkillController extends BaseApiController { from, to, userId, - userNsRoles != null ? userNsRoles : Map.of() + userNsRoles != null ? userNsRoles : Map.of(), + normalizePlatformRoles(platformRoles) ); return ok("response.success.read", toCompareResponse(compare)); @@ -209,14 +221,16 @@ public class SkillController extends BaseApiController { @PathVariable String slug, @PathVariable String version, @RequestAttribute(value = "userId", required = false) String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestAttribute(value = "platformRoles", required = false) Set platformRoles) { List files = skillQueryService.listFiles( namespace, slug, version, userId, - userNsRoles != null ? userNsRoles : Map.of() + userNsRoles != null ? userNsRoles : Map.of(), + normalizePlatformRoles(platformRoles) ); List response = files.stream() @@ -238,14 +252,16 @@ public class SkillController extends BaseApiController { @PathVariable String slug, @PathVariable String tagName, @RequestAttribute(value = "userId", required = false) String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestAttribute(value = "platformRoles", required = false) Set platformRoles) { List files = skillQueryService.listFilesByTag( namespace, slug, tagName, userId, - userNsRoles != null ? userNsRoles : Map.of() + userNsRoles != null ? userNsRoles : Map.of(), + normalizePlatformRoles(platformRoles) ); List response = files.stream() @@ -272,7 +288,8 @@ public class SkillController extends BaseApiController { @PathVariable String version, @RequestParam("path") String path, @RequestAttribute(value = "userId", required = false) String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestAttribute(value = "platformRoles", required = false) Set platformRoles) { InputStream content = skillQueryService.getFileContent( namespace, @@ -280,7 +297,8 @@ public class SkillController extends BaseApiController { version, path, userId, - userNsRoles != null ? userNsRoles : Map.of() + userNsRoles != null ? userNsRoles : Map.of(), + normalizePlatformRoles(platformRoles) ); return ResponseEntity.ok() @@ -295,7 +313,8 @@ public class SkillController extends BaseApiController { @PathVariable String tagName, @RequestParam("path") String path, @RequestAttribute(value = "userId", required = false) String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestAttribute(value = "platformRoles", required = false) Set platformRoles) { InputStream content = skillQueryService.getFileContentByTag( namespace, @@ -303,7 +322,8 @@ public class SkillController extends BaseApiController { tagName, path, userId, - userNsRoles != null ? userNsRoles : Map.of() + userNsRoles != null ? userNsRoles : Map.of(), + normalizePlatformRoles(platformRoles) ); return ResponseEntity.ok() @@ -323,7 +343,8 @@ public class SkillController extends BaseApiController { @RequestParam(required = false) String tag, @RequestParam(required = false) String hash, @RequestAttribute(value = "userId", required = false) String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestAttribute(value = "platformRoles", required = false) Set platformRoles) { SkillQueryService.ResolvedVersionDTO resolved = skillQueryService.resolveVersion( namespace, @@ -332,7 +353,8 @@ public class SkillController extends BaseApiController { tag, hash, userId, - userNsRoles != null ? userNsRoles : Map.of() + userNsRoles != null ? userNsRoles : Map.of(), + normalizePlatformRoles(platformRoles) ); ResolveVersionResponse response = new ResolveVersionResponse( @@ -356,10 +378,15 @@ public class SkillController extends BaseApiController { @PathVariable String slug, HttpServletRequest request, @RequestAttribute(value = "userId", required = false) String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestAttribute(value = "platformRoles", required = false) Set platformRoles) { SkillDownloadService.DownloadResult result = skillDownloadService.downloadLatest( - namespace, slug, userId, userNsRoles != null ? userNsRoles : Map.of()); + namespace, + slug, + userId, + userNsRoles != null ? userNsRoles : Map.of(), + normalizePlatformRoles(platformRoles)); return buildDownloadResponse(request, result); } @@ -372,10 +399,16 @@ public class SkillController extends BaseApiController { @PathVariable String version, HttpServletRequest request, @RequestAttribute(value = "userId", required = false) String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestAttribute(value = "platformRoles", required = false) Set platformRoles) { SkillDownloadService.DownloadResult result = skillDownloadService.downloadVersion( - namespace, slug, version, userId, userNsRoles != null ? userNsRoles : Map.of()); + namespace, + slug, + version, + userId, + userNsRoles != null ? userNsRoles : Map.of(), + normalizePlatformRoles(platformRoles)); return buildDownloadResponse(request, result); } @@ -388,10 +421,16 @@ public class SkillController extends BaseApiController { @PathVariable String tagName, HttpServletRequest request, @RequestAttribute(value = "userId", required = false) String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestAttribute(value = "platformRoles", required = false) Set platformRoles) { SkillDownloadService.DownloadResult result = skillDownloadService.downloadByTag( - namespace, slug, tagName, userId, userNsRoles != null ? userNsRoles : Map.of()); + namespace, + slug, + tagName, + userId, + userNsRoles != null ? userNsRoles : Map.of(), + normalizePlatformRoles(platformRoles)); return buildDownloadResponse(request, result); } @@ -418,6 +457,10 @@ public class SkillController extends BaseApiController { .body(new InputStreamResource(result.openContent())); } + private Set normalizePlatformRoles(Set platformRoles) { + return platformRoles != null ? platformRoles : Set.of(); + } + private boolean shouldRedirectToPresignedUrl(HttpServletRequest request, String presignedUrl) { if (presignedUrl == null || presignedUrl.isBlank()) { return false; 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 a53ee303..e1312d03 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 @@ -1,8 +1,12 @@ package com.iflytek.skillhub.filter; import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.auth.entity.UserRoleBinding; import com.iflytek.skillhub.auth.policy.RouteSecurityPolicyRegistry; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.rbac.PlatformRoleDefaults; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.auth.session.PlatformSessionService; import com.iflytek.skillhub.domain.namespace.NamespaceMember; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; @@ -14,7 +18,9 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpSession; import java.io.IOException; +import java.util.List; import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; import org.springframework.boot.autoconfigure.security.SecurityProperties; import org.springframework.beans.factory.annotation.Value; @@ -35,6 +41,8 @@ public class AuthContextFilter extends OncePerRequestFilter { private final NamespaceMemberRepository namespaceMemberRepository; private final UserAccountRepository userAccountRepository; + private final UserRoleBindingRepository userRoleBindingRepository; + private final PlatformSessionService platformSessionService; private final ApiResponseFactory apiResponseFactory; private final ObjectMapper objectMapper; private final boolean enforceActiveUserCheck; @@ -42,12 +50,16 @@ public class AuthContextFilter extends OncePerRequestFilter { public AuthContextFilter(NamespaceMemberRepository namespaceMemberRepository, UserAccountRepository userAccountRepository, + UserRoleBindingRepository userRoleBindingRepository, + PlatformSessionService platformSessionService, ApiResponseFactory apiResponseFactory, ObjectMapper objectMapper, @Value("${skillhub.auth.enforce-active-user-check:true}") boolean enforceActiveUserCheck, RouteSecurityPolicyRegistry routeSecurityPolicyRegistry) { this.namespaceMemberRepository = namespaceMemberRepository; this.userAccountRepository = userAccountRepository; + this.userRoleBindingRepository = userRoleBindingRepository; + this.platformSessionService = platformSessionService; this.apiResponseFactory = apiResponseFactory; this.objectMapper = objectMapper; this.enforceActiveUserCheck = enforceActiveUserCheck; @@ -75,8 +87,9 @@ public class AuthContextFilter extends OncePerRequestFilter { ); return; } + principal = refreshSessionRolesIfNeeded(principal, request); request.setAttribute("userId", principal.userId()); - request.setAttribute("platformRoles", principal.platformRoles() != null ? principal.platformRoles() : java.util.Set.of()); + request.setAttribute("platformRoles", platformRoles(principal)); Map userNsRoles = namespaceMemberRepository.findByUserId(principal.userId()).stream() .collect(Collectors.toMap( NamespaceMember::getNamespaceId, @@ -105,6 +118,36 @@ public class AuthContextFilter extends OncePerRequestFilter { .orElse(true); } + private PlatformPrincipal refreshSessionRolesIfNeeded(PlatformPrincipal principal, HttpServletRequest request) { + HttpSession session = request.getSession(false); + if (session == null || !(session.getAttribute("platformPrincipal") instanceof PlatformPrincipal)) { + return principal; + } + + List roleBindings = userRoleBindingRepository.findByUserId(principal.userId()); + Set freshRoles = PlatformRoleDefaults.withDefaultUserRole( + (roleBindings != null ? roleBindings : List.of()).stream() + .map(binding -> binding.getRole().getCode()) + .collect(Collectors.toSet())); + if (freshRoles.equals(platformRoles(principal))) { + return principal; + } + + PlatformPrincipal refreshedPrincipal = new PlatformPrincipal( + principal.userId(), + principal.displayName(), + principal.email(), + principal.avatarUrl(), + principal.oauthProvider(), + freshRoles); + platformSessionService.establishSession(refreshedPrincipal, request, false); + return refreshedPrincipal; + } + + private Set platformRoles(PlatformPrincipal principal) { + return principal.platformRoles() != null ? principal.platformRoles() : Set.of(); + } + private void clearAuthentication(HttpServletRequest request) { SecurityContextHolder.clearContext(); HttpSession session = request.getSession(false); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java index e8df0ad9..6b13fa22 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java @@ -16,6 +16,7 @@ import com.iflytek.skillhub.dto.MemberResponse; import com.iflytek.skillhub.dto.MyNamespaceResponse; import com.iflytek.skillhub.dto.NamespaceResponse; import com.iflytek.skillhub.dto.PageResponse; +import java.util.ArrayList; import java.util.Comparator; import java.util.List; import java.util.Map; @@ -26,6 +27,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -36,6 +38,11 @@ import org.springframework.transaction.annotation.Transactional; @Service public class NamespacePortalQueryAppService { + private static final String SUPER_ADMIN_ROLE = "SUPER_ADMIN"; + private static final String NAMESPACE_SLUG_SORT = "slug"; + private static final int DEFAULT_MY_NAMESPACE_PAGE_SIZE = 20; + private static final int MAX_MY_NAMESPACE_PAGE_SIZE = 100; + private final NamespaceRepository namespaceRepository; private final NamespaceService namespaceService; private final NamespaceMemberService namespaceMemberService; @@ -56,6 +63,25 @@ public class NamespacePortalQueryAppService { @Transactional(readOnly = true) public PageResponse listNamespaces(Pageable pageable, Map userNamespaceRoles) { + return listNamespaces(pageable, userNamespaceRoles, Set.of()); + } + + @Transactional(readOnly = true) + public PageResponse listNamespaces(Pageable pageable, + Map userNamespaceRoles, + Set platformRoles) { + if (isSuperAdmin(platformRoles)) { + Page namespaces = namespaceRepository.findByStatus( + NamespaceStatus.ACTIVE, + PageRequest.of( + pageable.getPageNumber(), + pageable.getPageSize(), + Sort.by(NAMESPACE_SLUG_SORT).ascending() + ) + ); + return PageResponse.from(namespaces.map(NamespaceResponse::from)); + } + Map namespaceRoles = userNamespaceRoles != null ? userNamespaceRoles : Map.of(); if (namespaceRoles.isEmpty()) { Page empty = new PageImpl<>( @@ -84,29 +110,125 @@ public class NamespacePortalQueryAppService { @Transactional(readOnly = true) public List listMyNamespaces(Map userNamespaceRoles) { + return listMyNamespaces(userNamespaceRoles, Set.of()); + } + + @Transactional(readOnly = true) + public List listMyNamespaces(Map userNamespaceRoles, + Set platformRoles) { Map namespaceRoles = userNamespaceRoles != null ? userNamespaceRoles : Map.of(); - if (namespaceRoles.isEmpty()) { + if (namespaceRoles.isEmpty() && !isSuperAdmin(platformRoles)) { return List.of(); } - return namespaceRepository.findByIdIn(namespaceRoles.keySet().stream().toList()).stream() - .sorted(Comparator.comparing(Namespace::getSlug)) - .map(namespace -> MyNamespaceResponse.from( - namespace, - namespaceRoles.get(namespace.getId()), - namespaceAccessPolicy, - namespaceService.canDelete(namespace, namespaceRoles.get(namespace.getId())))) + List visibleNamespaces = isSuperAdmin(platformRoles) + ? listAllNamespacesByPage() + : namespaceRepository.findByIdIn(namespaceRoles.keySet().stream().toList()).stream() + .sorted(Comparator.comparing(Namespace::getSlug)) + .toList(); + + return visibleNamespaces.stream() + .map(namespace -> myNamespaceResponse(namespace, namespaceRoles)) .toList(); } @Transactional(readOnly = true) - public NamespaceResponse getNamespace(String slug, String userId, Map userNamespaceRoles) { + public PageResponse listMyNamespaces(Pageable pageable, + Map userNamespaceRoles, + Set platformRoles) { Map namespaceRoles = userNamespaceRoles != null ? userNamespaceRoles : Map.of(); - Namespace namespace = namespaceService.getNamespaceBySlugForRead( - slug, - userId, - namespaceRoles); - if (!namespaceRoles.containsKey(namespace.getId())) { + Pageable boundedPageable = normalizeMyNamespacesPageable(pageable); + if (namespaceRoles.isEmpty() && !isSuperAdmin(platformRoles)) { + Page empty = new PageImpl<>(List.of(), boundedPageable, 0); + return PageResponse.from(empty); + } + + if (isSuperAdmin(platformRoles)) { + Page visibleNamespaces = namespaceRepository.findAll(boundedPageable); + return PageResponse.from(visibleNamespaces.map(namespace -> myNamespaceResponse(namespace, namespaceRoles))); + } + + List visibleNamespaces = namespaceRepository.findByIdIn(namespaceRoles.keySet().stream().toList()).stream() + .sorted(Comparator.comparing(Namespace::getSlug)) + .toList(); + int fromIndex = Math.min((int) boundedPageable.getOffset(), visibleNamespaces.size()); + int toIndex = Math.min(fromIndex + boundedPageable.getPageSize(), visibleNamespaces.size()); + Page responsePage = new PageImpl<>( + visibleNamespaces.subList(fromIndex, toIndex).stream() + .map(namespace -> myNamespaceResponse(namespace, namespaceRoles)) + .toList(), + boundedPageable, + visibleNamespaces.size() + ); + return PageResponse.from(responsePage); + } + + @Transactional(readOnly = true) + public PageResponse listMyNamespaces(Pageable pageable, + Map userNamespaceRoles, + Set platformRoles, + NamespaceStatus status, + NamespaceType type, + String query, + String slug, + Set roles) { + Map namespaceRoles = userNamespaceRoles != null ? userNamespaceRoles : Map.of(); + Set requestedRoles = roles != null ? roles : Set.of(); + Pageable boundedPageable = normalizeMyNamespacesPageable(pageable); + String normalizedQuery = normalizeSearchFilter(query); + String normalizedSlug = normalizeFilter(slug); + + if (isSuperAdmin(platformRoles) && requestedRoles.isEmpty()) { + Page visibleNamespaces = namespaceRepository.search( + status, + type, + normalizedQuery, + normalizedSlug, + boundedPageable + ); + return PageResponse.from(visibleNamespaces.map(namespace -> myNamespaceResponse(namespace, namespaceRoles))); + } + + List scopedNamespaceIds = namespaceRoles.entrySet().stream() + .filter(entry -> requestedRoles.isEmpty() || requestedRoles.contains(entry.getValue())) + .map(Map.Entry::getKey) + .sorted() + .toList(); + if (scopedNamespaceIds.isEmpty()) { + Page empty = new PageImpl<>(List.of(), boundedPageable, 0); + return PageResponse.from(empty); + } + + Page visibleNamespaces = namespaceRepository.searchByIdIn( + scopedNamespaceIds, + status, + type, + normalizedQuery, + normalizedSlug, + boundedPageable + ); + return PageResponse.from(visibleNamespaces.map(namespace -> myNamespaceResponse(namespace, namespaceRoles))); + } + + @Transactional(readOnly = true) + public NamespaceResponse getNamespace(String slug, String userId, Map userNamespaceRoles) { + return getNamespace(slug, userId, userNamespaceRoles, Set.of()); + } + + @Transactional(readOnly = true) + public NamespaceResponse getNamespace(String slug, + String userId, + Map userNamespaceRoles, + Set platformRoles) { + Map namespaceRoles = userNamespaceRoles != null ? userNamespaceRoles : Map.of(); + boolean superAdmin = isSuperAdmin(platformRoles); + Namespace namespace = superAdmin + ? namespaceService.getNamespaceBySlug(slug) + : namespaceService.getNamespaceBySlugForRead( + slug, + userId, + namespaceRoles); + if (!superAdmin && !namespaceRoles.containsKey(namespace.getId())) { throw new DomainForbiddenException("error.namespace.membership.required"); } return NamespaceResponse.from(namespace); @@ -138,4 +260,76 @@ public class NamespacePortalQueryAppService { MemberResponse.from(member, userMap.get(member.getUserId())) )); } + + private MyNamespaceResponse myNamespaceResponse(Namespace namespace, Map namespaceRoles) { + NamespaceRole currentUserRole = namespaceRoles.get(namespace.getId()); + return MyNamespaceResponse.from( + namespace, + currentUserRole, + namespaceAccessPolicy, + namespaceService.canDelete(namespace, currentUserRole)); + } + + private List listAllNamespacesByPage() { + List namespaces = new ArrayList<>(); + int pageNumber = 0; + Page page; + do { + page = namespaceRepository.findAll(PageRequest.of( + pageNumber, + MAX_MY_NAMESPACE_PAGE_SIZE, + Sort.by(NAMESPACE_SLUG_SORT).ascending() + )); + namespaces.addAll(page.getContent()); + pageNumber++; + if (page.getContent().isEmpty()) { + break; + } + } while (!page.isLast() && namespaces.size() < page.getTotalElements()); + return namespaces; + } + + private Pageable normalizeMyNamespacesPageable(Pageable pageable) { + int page = pageable != null && pageable.isPaged() + ? Math.max(pageable.getPageNumber(), 0) + : 0; + int requestedSize = pageable != null && pageable.isPaged() + ? pageable.getPageSize() + : DEFAULT_MY_NAMESPACE_PAGE_SIZE; + int size = Math.min(Math.max(requestedSize, 1), MAX_MY_NAMESPACE_PAGE_SIZE); + return PageRequest.of(page, size, normalizeMyNamespacesSort(pageable)); + } + + private Sort normalizeMyNamespacesSort(Pageable pageable) { + if (pageable == null || pageable.getSort().isUnsorted()) { + return Sort.by(NAMESPACE_SLUG_SORT).ascending(); + } + Sort.Order slugOrder = pageable.getSort().getOrderFor(NAMESPACE_SLUG_SORT); + if (slugOrder == null) { + return Sort.by(NAMESPACE_SLUG_SORT).ascending(); + } + return Sort.by(new Sort.Order(slugOrder.getDirection(), NAMESPACE_SLUG_SORT)); + } + + private String normalizeFilter(String value) { + if (value == null || value.isBlank()) { + return null; + } + return value.trim(); + } + + private String normalizeSearchFilter(String value) { + String normalized = normalizeFilter(value); + if (normalized == null) { + return null; + } + return normalized + .replace("!", "!!") + .replace("%", "!%") + .replace("_", "!_"); + } + + private boolean isSuperAdmin(Set platformRoles) { + return platformRoles != null && platformRoles.contains(SUPER_ADMIN_ROLE); + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java index 63410678..eda2f0dc 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java @@ -13,6 +13,7 @@ import com.iflytek.skillhub.search.SearchQuery; import com.iflytek.skillhub.search.SearchQueryService; import com.iflytek.skillhub.search.SearchResult; import com.iflytek.skillhub.search.SearchVisibilityScope; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -37,6 +38,7 @@ public class SkillSearchAppService { private final NamespaceService namespaceService; private final SkillLifecycleProjectionService skillLifecycleProjectionService; private final RbacService rbacService; + private static final String SUPER_ADMIN_ROLE = "SUPER_ADMIN"; public SkillSearchAppService( SearchQueryService searchQueryService, @@ -81,9 +83,10 @@ public class SkillSearchAppService { String userId, Map userNsRoles) { - Long namespaceId = resolveNamespaceId(namespaceSlug, userId, userNsRoles); + Set platformRoles = userId != null ? rbacService.getUserRoleCodes(userId) : Set.of(); + Long namespaceId = resolveNamespaceId(namespaceSlug, userId, userNsRoles, platformRoles); - SearchVisibilityScope scope = buildVisibilityScope(userId, userNsRoles); + SearchVisibilityScope scope = buildVisibilityScope(userId, userNsRoles, platformRoles, namespaceId); return searchVisibleSkills(keyword, namespaceId, sortBy != null ? sortBy : "newest", page, size, labelSlugs, scope, false); } @@ -96,36 +99,47 @@ public class SkillSearchAppService { int size, String userId, Map userNsRoles) { - Long namespaceId = resolveNamespaceId(namespaceSlug, userId, userNsRoles); - SearchVisibilityScope scope = buildVisibilityScope(userId, userNsRoles); + Set platformRoles = userId != null ? rbacService.getUserRoleCodes(userId) : Set.of(); + Long namespaceId = resolveNamespaceId(namespaceSlug, userId, userNsRoles, platformRoles); + SearchVisibilityScope scope = buildVisibilityScope(userId, userNsRoles, platformRoles, namespaceId); return searchVisibleSkills(keyword, namespaceId, sortBy != null ? sortBy : "newest", page, size, List.of(), scope, true); } - private Long resolveNamespaceId(String namespaceSlug, String userId, Map userNsRoles) { + private Long resolveNamespaceId(String namespaceSlug, + String userId, + Map userNsRoles, + Set platformRoles) { if (namespaceSlug == null || namespaceSlug.isBlank()) { return null; } + if (hasSuperAdminRole(platformRoles)) { + return namespaceService.getNamespaceBySlug(namespaceSlug).getId(); + } return namespaceService.getNamespaceBySlugForRead(namespaceSlug, userId, userNsRoles != null ? userNsRoles : Map.of()).getId(); } - private SearchVisibilityScope buildVisibilityScope(String userId, Map userNsRoles) { + private SearchVisibilityScope buildVisibilityScope(String userId, + Map userNsRoles, + Set platformRoles, + Long selectedNamespaceId) { if (userId == null) { return SearchVisibilityScope.anonymous(); } Map normalizedRoles = userNsRoles != null ? userNsRoles : Map.of(); - Set memberNamespaceIds = normalizedRoles.keySet(); + Set memberNamespaceIds = new HashSet<>(normalizedRoles.keySet()); + if (hasSuperAdminRole(platformRoles) && selectedNamespaceId != null) { + memberNamespaceIds.add(selectedNamespaceId); + } Set adminNamespaceIds = normalizedRoles.entrySet().stream() .filter(e -> e.getValue() == NamespaceRole.ADMIN) .map(Map.Entry::getKey) - .collect(java.util.stream.Collectors.toSet()); + .collect(java.util.stream.Collectors.toCollection(HashSet::new)); adminNamespaceIds.addAll(normalizedRoles.entrySet().stream() .filter(e -> e.getValue() == NamespaceRole.OWNER) .map(Map.Entry::getKey) .toList()); - Set platformRoles = rbacService.getUserRoleCodes(userId); - return new SearchVisibilityScope( userId, memberNamespaceIds, @@ -134,6 +148,10 @@ public class SkillSearchAppService { ); } + private boolean hasSuperAdminRole(Set platformRoles) { + return platformRoles != null && platformRoles.contains(SUPER_ADMIN_ROLE); + } + private boolean hasPlatformWideReadAccess(Set platformRoles) { // Super admins should use a dedicated admin interface, not the public portal return false; diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java index 566d3ef0..28fc2e56 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java @@ -1,7 +1,12 @@ package com.iflytek.skillhub.controller; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.auth.entity.Role; +import com.iflytek.skillhub.auth.entity.UserRoleBinding; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceGovernanceService; import com.iflytek.skillhub.domain.namespace.NamespaceMember; @@ -24,19 +29,26 @@ import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.web.context.HttpSessionSecurityContextRepository; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.RequestPostProcessor; +import org.springframework.mock.web.MockHttpSession; import java.util.List; import java.util.Map; import java.util.Set; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.nullable; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; @@ -53,6 +65,9 @@ class NamespacePortalControllerTest { @Autowired private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + @MockBean private NamespaceService namespaceService; @@ -77,6 +92,185 @@ class NamespacePortalControllerTest { @MockBean private UserAccountRepository userAccountRepository; + @MockBean + private UserRoleBindingRepository userRoleBindingRepository; + + @Test + void listNamespaces_superAdminReturnsAllActiveNamespacesWithoutMembership() throws Exception { + Namespace teamA = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM); + Namespace teamB = namespace(2L, "team-b", NamespaceStatus.ACTIVE, NamespaceType.TEAM); + given(namespaceMemberRepository.findByUserId("super-1")).willReturn(List.of()); + given(namespaceRepository.findByStatus(eq(NamespaceStatus.ACTIVE), any())) + .willReturn(new org.springframework.data.domain.PageImpl<>( + List.of(teamA, teamB), + org.springframework.data.domain.PageRequest.of(0, 20), + 2 + )); + + mockMvc.perform(get("/api/v1/namespaces") + .with(auth("super-1", Set.of("SUPER_ADMIN"))) + .requestAttr("userId", "super-1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.items[0].slug").value("team-a")) + .andExpect(jsonPath("$.data.items[1].slug").value("team-b")) + .andExpect(jsonPath("$.data.total").value(2)); + } + + @Test + void listMyNamespaces_superAdminKeepsLegacyArrayContract() throws Exception { + Namespace active = namespace(1L, "active", NamespaceStatus.ACTIVE, NamespaceType.TEAM); + Namespace archived = namespace(3L, "archived", NamespaceStatus.ARCHIVED, NamespaceType.TEAM); + given(namespaceMemberRepository.findByUserId("super-1")).willReturn(List.of()); + given(namespaceRepository.findAll(any())) + .willReturn(new org.springframework.data.domain.PageImpl<>( + List.of(active, archived), + org.springframework.data.domain.PageRequest.of(0, 2), + 2 + )); + + mockMvc.perform(get("/api/v1/me/namespaces") + .param("page", "0") + .param("size", "2") + .with(auth("super-1", Set.of("SUPER_ADMIN"))) + .requestAttr("userId", "super-1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data[0].slug").value("active")) + .andExpect(jsonPath("$.data[0].currentUserRole").doesNotExist()) + .andExpect(jsonPath("$.data[1].slug").value("archived")) + .andExpect(jsonPath("$.data.items").doesNotExist()); + } + + @Test + void listMyNamespacesPage_superAdminReturnsPagedNamespacesWithoutMembership() throws Exception { + Namespace active = namespace(1L, "active", NamespaceStatus.ACTIVE, NamespaceType.TEAM); + Namespace archived = namespace(3L, "archived", NamespaceStatus.ARCHIVED, NamespaceType.TEAM); + given(namespaceMemberRepository.findByUserId("super-1")).willReturn(List.of()); + given(namespaceRepository.search(eq(null), eq(null), eq(null), eq(null), any())) + .willReturn(new org.springframework.data.domain.PageImpl<>( + List.of(active, archived), + org.springframework.data.domain.PageRequest.of(0, 2), + 3 + )); + + mockMvc.perform(get("/api/web/me/namespaces/page") + .param("page", "0") + .param("size", "2") + .with(auth("super-1", Set.of("SUPER_ADMIN"))) + .requestAttr("userId", "super-1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.items[0].slug").value("active")) + .andExpect(jsonPath("$.data.items[0].currentUserRole").doesNotExist()) + .andExpect(jsonPath("$.data.items[1].slug").value("archived")) + .andExpect(jsonPath("$.data.total").value(3)) + .andExpect(jsonPath("$.data.page").value(0)) + .andExpect(jsonPath("$.data.size").value(2)); + } + + @Test + void listMyNamespacesPage_bindsAndAppliesOptionalFilters() throws Exception { + Namespace active = namespace(1L, "team-ai", NamespaceStatus.ACTIVE, NamespaceType.TEAM); + given(namespaceMemberRepository.findByUserId("owner-1")) + .willReturn(List.of(new NamespaceMember(1L, "owner-1", NamespaceRole.OWNER))); + given(namespaceRepository.searchByIdIn( + eq(List.of(1L)), + eq(NamespaceStatus.ACTIVE), + eq(NamespaceType.TEAM), + eq("team"), + eq("team-ai"), + any() + )).willReturn(new org.springframework.data.domain.PageImpl<>( + List.of(active), + org.springframework.data.domain.PageRequest.of(0, 20), + 1 + )); + + mockMvc.perform(get("/api/v1/me/namespaces/page") + .param("page", "0") + .param("size", "20") + .param("status", "ACTIVE") + .param("type", "TEAM") + .param("q", "team") + .param("slug", "team-ai") + .param("roles", "OWNER", "ADMIN") + .with(auth("owner-1")) + .requestAttr("userId", "owner-1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].slug").value("team-ai")) + .andExpect(jsonPath("$.data.items[0].currentUserRole").value("OWNER")) + .andExpect(jsonPath("$.data.total").value(1)); + } + + @Test + void listMyNamespacesPage_refreshesGrantedSuperAdminRoleForExistingSession() throws Exception { + Namespace active = namespace(1L, "active", NamespaceStatus.ACTIVE, NamespaceType.TEAM); + Namespace archived = namespace(2L, "archived", NamespaceStatus.ARCHIVED, NamespaceType.TEAM); + PlatformPrincipal stalePrincipal = principal("target-1", Set.of("USER")); + MockHttpSession session = sessionWithPrincipal(stalePrincipal); + + given(userRoleBindingRepository.findByUserId("target-1")) + .willReturn(List.of(new UserRoleBinding("target-1", role("SUPER_ADMIN")))); + given(namespaceMemberRepository.findByUserId("target-1")).willReturn(List.of()); + given(namespaceRepository.search(eq(null), eq(null), eq(null), eq(null), any())) + .willReturn(new org.springframework.data.domain.PageImpl<>( + List.of(active, archived), + org.springframework.data.domain.PageRequest.of(0, 20), + 2 + )); + + mockMvc.perform(get("/api/v1/me/namespaces/page") + .param("page", "0") + .param("size", "20") + .session(session) + .with(auth(stalePrincipal))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].slug").value("active")) + .andExpect(jsonPath("$.data.items[1].slug").value("archived")) + .andExpect(jsonPath("$.data.total").value(2)); + + PlatformPrincipal refreshedPrincipal = (PlatformPrincipal) session.getAttribute("platformPrincipal"); + assertThat(refreshedPrincipal.platformRoles()).containsExactlyInAnyOrder("SUPER_ADMIN"); + assertThat(sessionAuthorities(session)).containsExactly("ROLE_SUPER_ADMIN"); + } + + @Test + void listMyNamespacesPage_refreshesRevokedSuperAdminRoleForExistingSession() throws Exception { + PlatformPrincipal stalePrincipal = principal("target-2", Set.of("SUPER_ADMIN")); + MockHttpSession session = sessionWithPrincipal(stalePrincipal); + + given(userRoleBindingRepository.findByUserId("target-2")).willReturn(List.of()); + given(namespaceMemberRepository.findByUserId("target-2")).willReturn(List.of()); + + mockMvc.perform(get("/api/v1/me/namespaces/page") + .param("page", "0") + .param("size", "20") + .session(session) + .with(auth(stalePrincipal))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items").isEmpty()) + .andExpect(jsonPath("$.data.total").value(0)); + + verify(namespaceRepository, never()).search(eq(null), eq(null), eq(null), eq(null), any()); + PlatformPrincipal refreshedPrincipal = (PlatformPrincipal) session.getAttribute("platformPrincipal"); + assertThat(refreshedPrincipal.platformRoles()).containsExactly("USER"); + assertThat(sessionAuthorities(session)).containsExactly("ROLE_USER"); + } + + @Test + void openApi_myNamespacesPageExposesFlatPagingParameters() throws Exception { + String body = mockMvc.perform(get("/v3/api-docs")) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + JsonNode document = objectMapper.readTree(body); + + assertMyNamespacesPageParameters(document, "/api/v1/me/namespaces/page"); + assertMyNamespacesPageParameters(document, "/api/web/me/namespaces/page"); + } + @Test void listMyNamespaces_returnsFrozenAndArchivedNamespacesWithCurrentRole() throws Exception { Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ARCHIVED, NamespaceType.TEAM); @@ -101,6 +295,20 @@ class NamespacePortalControllerTest { .andExpect(status().isUnauthorized()); } + @Test + void getNamespace_superAdminReadsNamespaceWithoutMembership() throws Exception { + Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM); + given(namespaceMemberRepository.findByUserId("super-1")).willReturn(List.of()); + given(namespaceService.getNamespaceBySlug("team-a")).willReturn(namespace); + + mockMvc.perform(get("/api/v1/namespaces/team-a") + .with(auth("super-1", Set.of("SUPER_ADMIN"))) + .requestAttr("userId", "super-1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.slug").value("team-a")); + } + @Test void archiveNamespace_returnsUpdatedNamespace() throws Exception { Namespace archived = namespace(1L, "team-a", NamespaceStatus.ARCHIVED, NamespaceType.TEAM); @@ -335,7 +543,21 @@ class NamespacePortalControllerTest { } private RequestPostProcessor auth(String userId, Set platformRoles) { - PlatformPrincipal principal = new PlatformPrincipal( + PlatformPrincipal principal = principal(userId, platformRoles); + return auth(principal); + } + + private RequestPostProcessor auth(PlatformPrincipal principal) { + UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken( + principal, + null, + authorities(principal.platformRoles()) + ); + return authentication(authenticationToken); + } + + private PlatformPrincipal principal(String userId, Set platformRoles) { + return new PlatformPrincipal( userId, userId, userId + "@example.com", @@ -343,12 +565,55 @@ class NamespacePortalControllerTest { "session", platformRoles ); - UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken( - principal, - null, - List.of(new SimpleGrantedAuthority("ROLE_USER")) - ); - return authentication(authenticationToken); + } + + private MockHttpSession sessionWithPrincipal(PlatformPrincipal principal) { + MockHttpSession session = new MockHttpSession(); + session.setAttribute("platformPrincipal", principal); + return session; + } + + private List authorities(Set platformRoles) { + Set roles = platformRoles == null || platformRoles.isEmpty() ? Set.of("USER") : platformRoles; + return roles.stream() + .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) + .toList(); + } + + private List sessionAuthorities(MockHttpSession session) { + SecurityContext context = (SecurityContext) session.getAttribute( + HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY); + return context.getAuthentication().getAuthorities().stream() + .map(Object::toString) + .toList(); + } + + private void assertMyNamespacesPageParameters(JsonNode document, String path) { + JsonNode parameters = document.at("/paths/" + escapeJsonPointer(path) + "/get/parameters"); + assertThat(parameters.isArray()).isTrue(); + List parameterNames = parameterNames(parameters); + assertThat(parameterNames) + .contains("page", "size", "sort", "status", "type", "q", "slug", "roles") + .doesNotContain("pageable"); + assertThat(parameter(parameters, "page").path("schema").path("type").asText()).isEqualTo("integer"); + assertThat(parameter(parameters, "size").path("schema").path("type").asText()).isEqualTo("integer"); + } + + private List parameterNames(JsonNode parameters) { + return java.util.stream.StreamSupport.stream(parameters.spliterator(), false) + .map(parameter -> parameter.path("name").asText()) + .toList(); + } + + private JsonNode parameter(JsonNode parameters, String name) { + return java.util.stream.StreamSupport.stream(parameters.spliterator(), false) + .filter(parameter -> name.equals(parameter.path("name").asText())) + .findFirst() + .orElseThrow(); + } + + private String escapeJsonPointer(String path) { + return path.replace("~", "~0").replace("/", "~1"); } private Namespace namespace(Long id, String slug, NamespaceStatus status, NamespaceType type) { @@ -368,4 +633,11 @@ class NamespacePortalControllerTest { throw new RuntimeException(e); } } + + private Role role(String code) { + Role role = new Role(); + ReflectionTestUtils.setField(role, "code", code); + ReflectionTestUtils.setField(role, "name", code); + return role; + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java index ce622374..5599d475 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java @@ -20,11 +20,13 @@ import org.springframework.test.web.servlet.MockMvc; import java.time.Instant; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.TimeZone; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.anySet; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.verify; import static org.mockito.ArgumentMatchers.any; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -57,7 +59,8 @@ class SkillControllerTest { eq("demo"), eq("1.0.0"), eq((String) null), - eq(Map.of()))) + eq(Map.of()), + anySet())) .thenReturn(new SkillQueryService.SkillVersionDetailDTO( 10L, "1.0.0", @@ -87,7 +90,8 @@ class SkillControllerTest { eq("demo"), eq("1.0.0"), eq((String) null), - eq(Map.of()))) + eq(Map.of()), + anySet())) .thenReturn(new SkillQueryService.SkillVersionDetailDTO( 10L, "1.0.0", @@ -122,7 +126,8 @@ class SkillControllerTest { eq("latest"), eq(null), eq((String) null), - eq(Map.of()))) + eq(Map.of()), + anySet())) .thenReturn(new SkillQueryService.ResolvedVersionDTO( 1L, "team", @@ -150,7 +155,8 @@ class SkillControllerTest { eq("team"), eq("demo"), eq((String) null), - eq(Map.of()))) + eq(Map.of()), + anySet())) .thenReturn(new SkillQueryService.SkillDetailDTO( 1L, "demo", @@ -198,7 +204,8 @@ class SkillControllerTest { eq("team"), eq("demo"), eq((String) null), - eq(Map.of()))) + eq(Map.of()), + anySet())) .thenThrow(new DomainForbiddenException("error.namespace.archived", "team")); mockMvc.perform(get("/api/web/skills/team/demo")) @@ -206,6 +213,31 @@ class SkillControllerTest { .andExpect(jsonPath("$.code").value(403)); } + @Test + void getSkillDetailShouldForwardPlatformRoles() throws Exception { + Set platformRoles = Set.of("SUPER_ADMIN"); + when(skillQueryService.getSkillDetail( + eq("team"), + eq("demo"), + eq("super-1"), + eq(Map.of()), + eq(platformRoles))) + .thenThrow(new DomainForbiddenException("test.platformRoles.forwarded")); + + mockMvc.perform(get("/api/web/skills/team/demo") + .requestAttr("userId", "super-1") + .requestAttr("userNsRoles", Map.of()) + .requestAttr("platformRoles", platformRoles)) + .andExpect(status().isForbidden()); + + verify(skillQueryService).getSkillDetail( + "team", + "demo", + "super-1", + Map.of(), + platformRoles); + } + @Test void listFilesByTagShouldReturnUnifiedEnvelope() throws Exception { when(skillQueryService.listFilesByTag( @@ -213,7 +245,8 @@ class SkillControllerTest { eq("demo"), eq("latest"), eq((String) null), - eq(Map.of()))) + eq(Map.of()), + anySet())) .thenReturn(List.of(new SkillFile(20L, "README.md", 32L, "text/markdown", "hash", "key"))); mockMvc.perform(get("/api/v1/skills/team/demo/tags/latest/files")) @@ -232,7 +265,8 @@ class SkillControllerTest { eq("demo"), eq((String) null), eq(Map.of()), - any())) + any(), + anySet())) .thenReturn(new org.springframework.data.domain.PageImpl<>(List.of(version))); when(skillQueryService.isDownloadAvailable(version)).thenReturn(false); @@ -249,7 +283,8 @@ class SkillControllerTest { eq("1.0.0"), eq("1.1.0"), eq((String) null), - eq(Map.of()))) + eq(Map.of()), + anySet())) .thenReturn(new SkillQueryService.SkillVersionCompareDTO( "1.0.0", "1.1.0", @@ -292,7 +327,8 @@ class SkillControllerTest { eq("1.0.0"), eq("1.0.0"), eq((String) null), - eq(Map.of()))) + eq(Map.of()), + anySet())) .thenThrow(new DomainBadRequestException("error.skill.version.compare.same")); mockMvc.perform(get("/api/v1/skills/team/demo/versions/compare") diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/DownloadRateLimitControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/DownloadRateLimitControllerTest.java index 42f0e360..b1ff4dea 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/DownloadRateLimitControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/DownloadRateLimitControllerTest.java @@ -3,6 +3,7 @@ package com.iflytek.skillhub.controller.portal; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anySet; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.times; @@ -22,6 +23,7 @@ import com.iflytek.skillhub.domain.skill.service.SkillQueryService; import com.iflytek.skillhub.ratelimit.RateLimiter; import java.io.ByteArrayInputStream; import java.util.Map; +import java.util.Set; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.springframework.beans.factory.annotation.Autowired; @@ -59,7 +61,7 @@ class DownloadRateLimitControllerTest { @Test void anonymousDownloadUsesIpAndSignedCookieBuckets() throws Exception { given(rateLimiter.tryAcquire(anyString(), anyInt(), anyInt())).willReturn(true); - given(skillDownloadService.downloadVersion("global", "demo-skill", "1.0.0", null, Map.of())) + given(skillDownloadService.downloadVersion("global", "demo-skill", "1.0.0", null, Map.of(), Set.of())) .willReturn(new SkillDownloadService.DownloadResult( () -> new ByteArrayInputStream("zip".getBytes()), "demo-skill-1.0.0.zip", @@ -101,6 +103,7 @@ class DownloadRateLimitControllerTest { .andExpect(status().isTooManyRequests()) .andExpect(jsonPath("$.code").value(429)); - verify(skillDownloadService, never()).downloadVersion(anyString(), anyString(), anyString(), anyString(), any()); + verify(skillDownloadService, never()).downloadVersion( + anyString(), anyString(), anyString(), anyString(), any(), anySet()); } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillControllerDownloadTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillControllerDownloadTest.java index 8945d2a9..ad0c7834 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillControllerDownloadTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillControllerDownloadTest.java @@ -59,7 +59,8 @@ class SkillControllerDownloadTest { @Test void downloadVersion_redirectsToPresignedUrlWhenAvailable() throws Exception { given(rateLimiter.tryAcquire(anyString(), anyInt(), anyInt())).willReturn(true); - given(skillDownloadService.downloadVersion("global", "demo-skill", "1.0.0", "test-user", java.util.Map.of())) + given(skillDownloadService.downloadVersion( + "global", "demo-skill", "1.0.0", "test-user", java.util.Map.of(), java.util.Set.of())) .willReturn(new SkillDownloadService.DownloadResult( () -> new ByteArrayInputStream("zip".getBytes()), "demo-skill-1.0.0.zip", @@ -80,7 +81,8 @@ class SkillControllerDownloadTest { @Test void downloadVersion_streamsWhenPresignedUrlIsInsecureForHttpsRequest() throws Exception { given(rateLimiter.tryAcquire(anyString(), anyInt(), anyInt())).willReturn(true); - given(skillDownloadService.downloadVersion("global", "demo-skill", "1.0.0", "test-user", java.util.Map.of())) + given(skillDownloadService.downloadVersion( + "global", "demo-skill", "1.0.0", "test-user", java.util.Map.of(), java.util.Set.of())) .willReturn(new SkillDownloadService.DownloadResult( () -> new ByteArrayInputStream("zip".getBytes()), "demo-skill-1.0.0.zip", @@ -102,7 +104,8 @@ class SkillControllerDownloadTest { @Test void downloadVersion_streamsWhenPresignedUrlUnavailable() throws Exception { given(rateLimiter.tryAcquire(anyString(), anyInt(), anyInt())).willReturn(true); - given(skillDownloadService.downloadVersion("global", "demo-skill", "1.0.0", "test-user", java.util.Map.of())) + given(skillDownloadService.downloadVersion( + "global", "demo-skill", "1.0.0", "test-user", java.util.Map.of(), java.util.Set.of())) .willReturn(new SkillDownloadService.DownloadResult( () -> new ByteArrayInputStream("zip".getBytes()), "demo-skill-1.0.0.zip", @@ -123,7 +126,8 @@ class SkillControllerDownloadTest { @Test void downloadVersion_allowsAnonymousForGlobalSkill() throws Exception { given(rateLimiter.tryAcquire(anyString(), anyInt(), anyInt())).willReturn(true); - given(skillDownloadService.downloadVersion("global", "demo-skill", "1.0.0", null, java.util.Map.of())) + given(skillDownloadService.downloadVersion( + "global", "demo-skill", "1.0.0", null, java.util.Map.of(), java.util.Set.of())) .willReturn(new SkillDownloadService.DownloadResult( () -> new ByteArrayInputStream("zip".getBytes()), "demo-skill-1.0.0.zip", @@ -143,7 +147,8 @@ class SkillControllerDownloadTest { @Test void downloadVersion_forbidsAnonymousWhenServiceRejectsSkill() throws Exception { given(rateLimiter.tryAcquire(anyString(), anyInt(), anyInt())).willReturn(true); - given(skillDownloadService.downloadVersion("team-ai", "demo-skill", "1.0.0", null, java.util.Map.of())) + given(skillDownloadService.downloadVersion( + "team-ai", "demo-skill", "1.0.0", null, java.util.Map.of(), java.util.Set.of())) .willThrow(new DomainForbiddenException("error.skill.access.denied", "demo-skill")); mockMvc.perform(get("/api/v1/skills/team-ai/demo-skill/versions/1.0.0/download") @@ -155,7 +160,8 @@ class SkillControllerDownloadTest { @Test void downloadVersion_redirectDoesNotOpenContentStream() throws Exception { given(rateLimiter.tryAcquire(anyString(), anyInt(), anyInt())).willReturn(true); - given(skillDownloadService.downloadVersion("global", "demo-skill", "1.0.0", "test-user", java.util.Map.of())) + given(skillDownloadService.downloadVersion( + "global", "demo-skill", "1.0.0", "test-user", java.util.Map.of(), java.util.Set.of())) .willReturn(new SkillDownloadService.DownloadResult( () -> { throw new AssertionError("content stream should not be opened for redirects"); @@ -178,7 +184,8 @@ class SkillControllerDownloadTest { @Test void downloadVersion_usesPerVersionRateLimitKey() throws Exception { given(rateLimiter.tryAcquire(anyString(), anyInt(), anyInt())).willReturn(true); - given(skillDownloadService.downloadVersion("global", "demo-skill", "1.0.0", "test-user", java.util.Map.of())) + given(skillDownloadService.downloadVersion( + "global", "demo-skill", "1.0.0", "test-user", java.util.Map.of(), java.util.Set.of())) .willReturn(new SkillDownloadService.DownloadResult( () -> new ByteArrayInputStream("zip".getBytes()), "demo-skill-1.0.0.zip", diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/AuthContextFilterTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/AuthContextFilterTest.java index c796bab6..7ab766d3 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/AuthContextFilterTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/AuthContextFilterTest.java @@ -3,7 +3,9 @@ package com.iflytek.skillhub.filter; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.iflytek.skillhub.auth.policy.RouteSecurityPolicyRegistry; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.session.PlatformSessionService; import com.iflytek.skillhub.domain.namespace.NamespaceMember; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; @@ -42,6 +44,8 @@ class AuthContextFilterTest { private final NamespaceMemberRepository namespaceMemberRepository = mock(NamespaceMemberRepository.class); private final UserAccountRepository userAccountRepository = mock(UserAccountRepository.class); + private final UserRoleBindingRepository userRoleBindingRepository = mock(UserRoleBindingRepository.class); + private final PlatformSessionService platformSessionService = mock(PlatformSessionService.class); private final AuthContextFilter filter; AuthContextFilterTest() { @@ -53,6 +57,8 @@ class AuthContextFilterTest { filter = new AuthContextFilter( namespaceMemberRepository, userAccountRepository, + userRoleBindingRepository, + platformSessionService, apiResponseFactory, new ObjectMapper().registerModule(new JavaTimeModule()), true, diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepositoryTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepositoryTest.java new file mode 100644 index 00000000..255387cb --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepositoryTest.java @@ -0,0 +1,87 @@ +package com.iflytek.skillhub.infra.jpa; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceStatus; +import com.iflytek.skillhub.domain.namespace.NamespaceType; +import jakarta.persistence.EntityManager; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.context.ActiveProfiles; + +@DataJpaTest +@ActiveProfiles("test") +class NamespaceJpaRepositoryTest { + + @Autowired + private NamespaceJpaRepository repository; + + @Autowired + private EntityManager entityManager; + + private Namespace percentNamespace; + private Namespace underscoreNamespace; + + @BeforeEach + void setUp() { + percentNamespace = persist(new Namespace("percent-team", "50% Tools", "owner-1")); + underscoreNamespace = persist(new Namespace("underscore-team", "50_Tools", "owner-1")); + persist(new Namespace("plain-team", "Plain Tools", "owner-1")); + Namespace archived = new Namespace("archived-percent", "50% Archived", "owner-1"); + archived.setStatus(NamespaceStatus.ARCHIVED); + persist(archived); + Namespace global = new Namespace("global", "50% Global", "owner-1"); + global.setType(NamespaceType.GLOBAL); + persist(global); + entityManager.flush(); + } + + @Test + void search_treatsEscapedWildcardsLiterallyAndAppliesStatus() { + var percentPage = repository.search( + NamespaceStatus.ACTIVE, + NamespaceType.TEAM, + "!%", + null, + PageRequest.of(0, 10) + ); + var underscorePage = repository.searchByIdIn( + List.of(percentNamespace.getId(), underscoreNamespace.getId()), + NamespaceStatus.ACTIVE, + NamespaceType.TEAM, + "!_", + null, + PageRequest.of(0, 10) + ); + + assertThat(percentPage.getContent()).extracting(Namespace::getSlug) + .containsExactly("percent-team"); + assertThat(percentPage.getTotalElements()).isEqualTo(1); + assertThat(underscorePage.getContent()).extracting(Namespace::getSlug) + .containsExactly("underscore-team"); + } + + @Test + void search_acceptsNullQueryAlongsideOtherFilters() { + var page = repository.search( + NamespaceStatus.ACTIVE, + NamespaceType.TEAM, + null, + "percent-team", + PageRequest.of(0, 1) + ); + + assertThat(page.getContent()).extracting(Namespace::getSlug) + .containsExactly("percent-team"); + } + + private Namespace persist(Namespace namespace) { + entityManager.persist(namespace); + return namespace; + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/security/ApiAccessDeniedHandlerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/security/ApiAccessDeniedHandlerTest.java index 00ca5e8a..23debbca 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/security/ApiAccessDeniedHandlerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/security/ApiAccessDeniedHandlerTest.java @@ -40,6 +40,7 @@ class ApiAccessDeniedHandlerTest { ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource(); messageSource.setBasename("messages"); messageSource.setDefaultEncoding("UTF-8"); + messageSource.setFallbackToSystemLocale(false); RequestIdAccessor requestIdAccessor = new RequestIdAccessor(); ApiResponseFactory responseFactory = new ApiResponseFactory( messageSource, diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/LabelSearchSyncIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/LabelSearchSyncIntegrationTest.java index 45719e31..f3f96cf5 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/LabelSearchSyncIntegrationTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/LabelSearchSyncIntegrationTest.java @@ -14,6 +14,9 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.namespace.NamespaceType; import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.dto.AdminLabelUpdateRequest; import com.iflytek.skillhub.dto.LabelTranslationItemRequest; @@ -71,6 +74,9 @@ class LabelSearchSyncIntegrationTest { @Autowired private SkillRepository skillRepository; + @Autowired + private SkillVersionRepository skillVersionRepository; + @Autowired private LabelDefinitionRepository labelDefinitionRepository; @@ -117,7 +123,7 @@ class LabelSearchSyncIntegrationTest { skill.setCreatedBy(ownerId); skill.setUpdatedBy(ownerId); skill = skillRepository.save(skill); - skillRepository.flush(); + publishLatestVersion(skill, ownerId); LabelDefinition label = labelDefinitionRepository.save( new LabelDefinition(labelSlug, LabelType.RECOMMENDED, true, 0, ownerId)); @@ -307,7 +313,7 @@ class LabelSearchSyncIntegrationTest { skill.setCreatedBy(ownerId); skill.setUpdatedBy(ownerId); skill = skillRepository.save(skill); - skillRepository.flush(); + publishLatestVersion(skill, ownerId); LabelDefinition label = labelDefinitionRepository.save( new LabelDefinition(labelSlug, LabelType.RECOMMENDED, true, 0, ownerId)); @@ -321,6 +327,18 @@ class LabelSearchSyncIntegrationTest { Map.of(namespace.getId(), NamespaceRole.OWNER)); } + private void publishLatestVersion(Skill skill, String ownerId) { + SkillVersion version = new SkillVersion(skill.getId(), "1.0.0", ownerId); + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setPublishedAt(Instant.now()); + version = skillVersionRepository.save(version); + + skill.setLatestVersionId(version.getId()); + skillRepository.save(skill); + skillVersionRepository.flush(); + skillRepository.flush(); + } + private record Fixture( String namespaceSlug, String skillSlug, diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java index 05a9fd3c..1a48cc0c 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java @@ -5,7 +5,11 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import com.iflytek.skillhub.domain.namespace.Namespace; @@ -25,6 +29,7 @@ import com.iflytek.skillhub.dto.PageResponse; import org.junit.jupiter.api.Test; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.test.util.ReflectionTestUtils; import java.util.List; @@ -78,6 +83,25 @@ class NamespacePortalQueryAppServiceTest { assertThat(response.get(1).canDelete()).isFalse(); } + @Test + void listNamespaces_superAdminReturnsAllActiveNamespaces() { + Namespace teamB = namespace(2L, "team-b"); + Namespace teamA = namespace(1L, "team-a"); + when(namespaceRepository.findByStatus(eq(NamespaceStatus.ACTIVE), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(teamA, teamB), PageRequest.of(0, 10), 2)); + + var response = service.listNamespaces( + PageRequest.of(0, 10), + Map.of(), + Set.of("SUPER_ADMIN") + ); + + assertThat(response.items()).hasSize(2); + assertThat(response.items().get(0).slug()).isEqualTo("team-a"); + assertThat(response.items().get(1).slug()).isEqualTo("team-b"); + assertThat(response.total()).isEqualTo(2); + } + @Test void listNamespaces_returnsOnlyCurrentUsersActiveNamespaces() { Namespace teamA = namespace(1L, "team-a"); @@ -101,16 +125,229 @@ class NamespacePortalQueryAppServiceTest { assertThat(response.items().get(1).slug()).isEqualTo("team-b"); } + @Test + void listMyNamespaces_superAdminReturnsPagedNamespacesWithoutGrantingNamespaceRole() { + Namespace active = namespace(1L, "active"); + Namespace archived = namespace(2L, "archived"); + archived.setStatus(NamespaceStatus.ARCHIVED); + Namespace frozen = namespace(3L, "frozen"); + frozen.setStatus(NamespaceStatus.FROZEN); + + when(namespaceRepository.findAll(any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(archived, frozen), PageRequest.of(1, 2), 4)); + + var response = service.listMyNamespaces(PageRequest.of(1, 2), Map.of(), Set.of("SUPER_ADMIN")); + + assertThat(response.items()).hasSize(2); + assertThat(response.items()).extracting("slug").containsExactly("archived", "frozen"); + assertThat(response.items()).extracting("currentUserRole").containsOnlyNulls(); + assertThat(response.items()).extracting("canFreeze").containsOnly(false); + assertThat(response.items()).extracting("canDelete").containsOnly(false); + assertThat(response.total()).isEqualTo(4); + assertThat(response.page()).isEqualTo(1); + assertThat(response.size()).isEqualTo(2); + } + + @Test + void listMyNamespaces_superAdminWithRequestedRolesSearchesOnlyMatchingMembershipIds() { + Namespace owned = namespace(1L, "team-ai"); + Pageable expectedPageable = PageRequest.of(0, 20); + when(namespaceRepository.searchByIdIn( + eq(List.of(1L)), + eq(NamespaceStatus.ACTIVE), + eq(NamespaceType.TEAM), + eq("team"), + eq("team-ai"), + any(Pageable.class) + )).thenReturn(new PageImpl<>(List.of(owned), expectedPageable, 1)); + + var response = service.listMyNamespaces( + expectedPageable, + Map.of(1L, NamespaceRole.OWNER, 2L, NamespaceRole.MEMBER), + Set.of("SUPER_ADMIN"), + NamespaceStatus.ACTIVE, + NamespaceType.TEAM, + " team ", + " team-ai ", + Set.of(NamespaceRole.OWNER, NamespaceRole.ADMIN) + ); + + assertThat(response.items()).extracting("slug").containsExactly("team-ai"); + assertThat(response.items()).extracting("currentUserRole").containsExactly(NamespaceRole.OWNER); + verify(namespaceRepository).searchByIdIn( + eq(List.of(1L)), + eq(NamespaceStatus.ACTIVE), + eq(NamespaceType.TEAM), + eq("team"), + eq("team-ai"), + any(Pageable.class) + ); + verify(namespaceRepository, never()).search(any(), any(), any(), any(), any()); + } + + @Test + void listMyNamespaces_superAdminWithoutRequestedRolesUsesUnrestrictedFilteredSearch() { + Namespace archived = namespace(2L, "ops-team"); + archived.setStatus(NamespaceStatus.ARCHIVED); + Pageable expectedPageable = PageRequest.of(1, 10); + when(namespaceRepository.search( + eq(NamespaceStatus.ARCHIVED), + eq(null), + eq("ops"), + eq("ops-team"), + any(Pageable.class) + )).thenReturn(new PageImpl<>(List.of(archived), expectedPageable, 11)); + + var response = service.listMyNamespaces( + expectedPageable, + Map.of(), + Set.of("SUPER_ADMIN"), + NamespaceStatus.ARCHIVED, + null, + " ops ", + " ops-team ", + Set.of() + ); + + assertThat(response.items()).extracting("slug").containsExactly("ops-team"); + assertThat(response.total()).isEqualTo(11); + verify(namespaceRepository).search( + eq(NamespaceStatus.ARCHIVED), + eq(null), + eq("ops"), + eq("ops-team"), + any(Pageable.class) + ); + verify(namespaceRepository, never()).searchByIdIn(anyList(), any(), any(), any(), any(), any()); + } + + @Test + void listMyNamespaces_escapesLikeWildcardsForLiteralSubstringSearch() { + Pageable expectedPageable = PageRequest.of(0, 20); + when(namespaceRepository.search( + eq(null), + eq(null), + eq("50!%!_!!off"), + eq(null), + any(Pageable.class) + )).thenReturn(new PageImpl<>(List.of(), expectedPageable, 0)); + + service.listMyNamespaces( + expectedPageable, + Map.of(), + Set.of("SUPER_ADMIN"), + null, + null, + " 50%_!off ", + null, + Set.of() + ); + + verify(namespaceRepository).search( + eq(null), + eq(null), + eq("50!%!_!!off"), + eq(null), + any(Pageable.class) + ); + } + + @Test + void listMyNamespaces_nonSuperAdminWithoutRequestedRolesSearchesAllMembershipIds() { + Namespace member = namespace(1L, "member-team"); + Namespace administered = namespace(2L, "admin-team"); + when(namespaceRepository.searchByIdIn( + eq(List.of(1L, 2L)), + eq(null), + eq(null), + eq(null), + eq(null), + any(Pageable.class) + )).thenReturn(new PageImpl<>(List.of(administered, member), PageRequest.of(0, 20), 2)); + + var response = service.listMyNamespaces( + PageRequest.of(0, 20), + Map.of(2L, NamespaceRole.ADMIN, 1L, NamespaceRole.MEMBER), + Set.of(), + null, + null, + " ", + "\t", + Set.of() + ); + + assertThat(response.items()).extracting("slug").containsExactly("admin-team", "member-team"); + assertThat(response.items()).extracting("currentUserRole") + .containsExactly(NamespaceRole.ADMIN, NamespaceRole.MEMBER); + verify(namespaceRepository).searchByIdIn( + eq(List.of(1L, 2L)), + eq(null), + eq(null), + eq(null), + eq(null), + any(Pageable.class) + ); + verify(namespaceRepository, never()).search(any(), any(), any(), any(), any()); + } + + @Test + void listMyNamespaces_emptyRoleRestrictedScopeReturnsEmptyPageWithoutRepositoryQuery() { + var response = service.listMyNamespaces( + PageRequest.of(2, 10), + Map.of(1L, NamespaceRole.MEMBER), + Set.of("SUPER_ADMIN"), + NamespaceStatus.ACTIVE, + NamespaceType.TEAM, + " team ", + null, + Set.of(NamespaceRole.OWNER, NamespaceRole.ADMIN) + ); + + assertThat(response.items()).isEmpty(); + assertThat(response.total()).isZero(); + assertThat(response.page()).isEqualTo(2); + assertThat(response.size()).isEqualTo(10); + verifyNoInteractions(namespaceRepository); + } + + @Test + void listMyNamespaces_superAdminCompatibilityCollectsAllRepositoryPages() { + Namespace first = namespace(1L, "first"); + Namespace second = namespace(2L, "second"); + Namespace third = namespace(3L, "third"); + + when(namespaceRepository.findAll(any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(first, second), PageRequest.of(0, 2), 3)) + .thenReturn(new PageImpl<>(List.of(third), PageRequest.of(1, 2), 3)); + + var response = service.listMyNamespaces(Map.of(), Set.of("SUPER_ADMIN")); + + assertThat(response).extracting("slug").containsExactly("first", "second", "third"); + assertThat(response).extracting("currentUserRole").containsOnlyNulls(); + } + @Test void getNamespace_throwsWhenCurrentUserIsNotNamespaceMember() { Namespace namespace = namespace(1L, "team-a"); when(namespaceService.getNamespaceBySlugForRead("team-a", "user-1", Map.of())) .thenReturn(namespace); - assertThatThrownBy(() -> service.getNamespace("team-a", "user-1", Map.of())) + assertThatThrownBy(() -> service.getNamespace("team-a", "user-1", Map.of(), Set.of())) .isInstanceOf(DomainForbiddenException.class); } + @Test + void getNamespace_superAdminReadsArchivedNamespaceWithoutMembership() { + Namespace archived = namespace(1L, "archived-team"); + archived.setStatus(NamespaceStatus.ARCHIVED); + when(namespaceService.getNamespaceBySlug("archived-team")).thenReturn(archived); + + var response = service.getNamespace("archived-team", "super-1", Map.of(), Set.of("SUPER_ADMIN")); + + assertThat(response.slug()).isEqualTo("archived-team"); + assertThat(response.status()).isEqualTo(NamespaceStatus.ARCHIVED); + } + private Namespace namespace(Long id, String slug) { Namespace namespace = new Namespace(slug, slug, "owner-1"); ReflectionTestUtils.setField(namespace, "id", id); @@ -175,4 +412,16 @@ class NamespacePortalQueryAppServiceTest { .isInstanceOf(DomainForbiddenException.class) .hasMessageContaining("error.namespace.global.members.platformAdmin.required"); } + + @Test + void listMembers_teamNamespaceRejectsSuperAdminWithoutMembership() { + Namespace ns = namespace(1L, "team-a"); + when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(ns); + doThrow(new DomainForbiddenException("error.namespace.membership.required")) + .when(namespaceService).assertMember(1L, "super-1"); + + assertThatThrownBy(() -> service.listMembers("team-a", PageRequest.of(0, 20), "super-1", Set.of("SUPER_ADMIN"))) + .isInstanceOf(DomainForbiddenException.class) + .hasMessageContaining("error.namespace.membership.required"); + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java index 3cd408e4..42285b62 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java @@ -127,6 +127,23 @@ class SkillSearchAppServiceTest { ); } + @Test + void search_shouldAllowSuperAdminToReadArchivedNamespaceSkillsWithoutMembership() { + Namespace archivedNamespace = new Namespace("archived-team", "Archived Team", "owner-1"); + setField(archivedNamespace, "id", 1L); + archivedNamespace.setStatus(NamespaceStatus.ARCHIVED); + when(rbacService.getUserRoleCodes("super-1")).thenReturn(Set.of("SUPER_ADMIN")); + when(namespaceService.getNamespaceBySlug("archived-team")).thenReturn(archivedNamespace); + when(searchQueryService.search(any())).thenReturn(new SearchResult(List.of(), 0, 0, 20)); + + service.search("skill", "archived-team", "newest", 0, 20, "super-1", Map.of()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SearchQuery.class); + verify(searchQueryService).search(captor.capture()); + assertEquals(1L, captor.getValue().namespaceId()); + assertEquals(Set.of(1L), captor.getValue().visibilityScope().memberNamespaceIds()); + } + @Test void search_shouldExcludeHiddenSkillsForRegularUsers() { Skill visibleSkill = new Skill(1L, "visible-skill", "owner-1", SkillVisibility.PUBLIC); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java index dc558a93..2f53f3da 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java @@ -13,7 +13,23 @@ public interface NamespaceRepository { Optional findById(Long id); List findByIdIn(List ids); Optional findBySlug(String slug); + Page findAll(Pageable pageable); Page findByStatus(NamespaceStatus status, Pageable pageable); + Page search( + NamespaceStatus status, + NamespaceType type, + String query, + String slug, + Pageable pageable + ); + Page searchByIdIn( + List ids, + NamespaceStatus status, + NamespaceType type, + String query, + String slug, + Pageable pageable + ); Namespace save(Namespace namespace); void delete(Namespace namespace); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java index 30b52a66..9d6b8b6d 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java @@ -33,6 +33,26 @@ public class VisibilityChecker { }; } + /** + * Applies portal namespace-read semantics without turning the caller into a namespace member. + * The override exposes published namespace-visible skills, but never private, hidden, or + * unpublished skills. + */ + public boolean canAccessForNamespaceRead( + Skill skill, + String currentUserId, + Map userNamespaceRoles, + boolean namespaceReadAllowed) { + if (canAccess(skill, currentUserId, userNamespaceRoles)) { + return true; + } + return namespaceReadAllowed + && !skill.isHidden() + && skill.getStatus() == SkillStatus.ACTIVE + && skill.getLatestVersionId() != null + && skill.getVisibility() == SkillVisibility.NAMESPACE_ONLY; + } + private boolean isOwner(Skill skill, String currentUserId) { return currentUserId != null && skill.getOwnerId().equals(currentUserId); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java index 294563ad..c2433fce 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java @@ -22,6 +22,7 @@ import java.time.Duration; import java.util.Comparator; import java.util.Map; import java.util.List; +import java.util.Set; import java.util.function.Supplier; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; @@ -91,10 +92,19 @@ public class SkillDownloadService { String skillSlug, String currentUserId, Map userNsRoles) { + return downloadLatest(namespaceSlug, skillSlug, currentUserId, userNsRoles, Set.of()); + } + + public DownloadResult downloadLatest( + String namespaceSlug, + String skillSlug, + String currentUserId, + Map userNsRoles, + Set platformRoles) { Namespace namespace = findNamespace(namespaceSlug); Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); - assertCanDownload(namespace, skill, currentUserId, userNsRoles); + assertCanDownload(namespace, skill, currentUserId, userNsRoles, platformRoles); if (skill.getLatestVersionId() == null) { throw new DomainBadRequestException("error.skill.version.latest.unavailable", skillSlug); @@ -116,10 +126,20 @@ public class SkillDownloadService { String versionStr, String currentUserId, Map userNsRoles) { + return downloadVersion(namespaceSlug, skillSlug, versionStr, currentUserId, userNsRoles, Set.of()); + } + + public DownloadResult downloadVersion( + String namespaceSlug, + String skillSlug, + String versionStr, + String currentUserId, + Map userNsRoles, + Set platformRoles) { Namespace namespace = findNamespace(namespaceSlug); Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); - assertCanDownload(namespace, skill, currentUserId, userNsRoles); + assertCanDownload(namespace, skill, currentUserId, userNsRoles, platformRoles); SkillVersion version = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), versionStr) .orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", versionStr)); @@ -136,10 +156,20 @@ public class SkillDownloadService { String tagName, String currentUserId, Map userNsRoles) { + return downloadByTag(namespaceSlug, skillSlug, tagName, currentUserId, userNsRoles, Set.of()); + } + + public DownloadResult downloadByTag( + String namespaceSlug, + String skillSlug, + String tagName, + String currentUserId, + Map userNsRoles, + Set platformRoles) { Namespace namespace = findNamespace(namespaceSlug); Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); - assertCanDownload(namespace, skill, currentUserId, userNsRoles); + assertCanDownload(namespace, skill, currentUserId, userNsRoles, platformRoles); SkillTag tag = skillTagRepository.findBySkillIdAndTagName(skill.getId(), tagName) .orElseThrow(() -> new DomainBadRequestException("error.skill.tag.notFound", tagName)); @@ -278,15 +308,20 @@ public class SkillDownloadService { private void assertCanDownload(Namespace namespace, Skill skill, String currentUserId, - Map userNsRoles) { + Map userNsRoles, + Set platformRoles) { if (currentUserId == null && !isAnonymousDownloadAllowed(skill)) { throw new DomainForbiddenException("error.skill.access.denied", skill.getSlug()); } - if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) { + boolean canAccess = isSuperAdmin(platformRoles) + ? visibilityChecker.canAccessForNamespaceRead(skill, currentUserId, userNsRoles, true) + : visibilityChecker.canAccess(skill, currentUserId, userNsRoles); + if (!canAccess) { throw new DomainForbiddenException("error.skill.access.denied", skill.getSlug()); } if (namespace.getStatus() == NamespaceStatus.ARCHIVED - && !isNamespaceMember(namespace.getId(), currentUserId, userNsRoles)) { + && !isNamespaceMember(namespace.getId(), currentUserId, userNsRoles) + && !isSuperAdmin(platformRoles)) { throw new DomainForbiddenException("error.namespace.archived", namespace.getSlug()); } } @@ -299,6 +334,10 @@ public class SkillDownloadService { return currentUserId != null && userNsRoles != null && userNsRoles.containsKey(namespaceId); } + private boolean isSuperAdmin(Set platformRoles) { + return platformRoles != null && platformRoles.contains("SUPER_ADMIN"); + } + private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) { return skillSlugResolutionService.resolve( namespaceId, diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java index 66c5e319..df7070df 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java @@ -198,15 +198,34 @@ public class SkillQueryService { String skillSlug, String currentUserId, Map userNsRoles) { + return getSkillDetail(namespaceSlug, skillSlug, currentUserId, userNsRoles, Set.of()); + } + + public SkillDetailDTO getSkillDetail( + String namespaceSlug, + String skillSlug, + String currentUserId, + Map userNsRoles, + Set platformRoles) { Namespace namespace = findNamespace(namespaceSlug); Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); if (namespace.getStatus() == com.iflytek.skillhub.domain.namespace.NamespaceStatus.ARCHIVED - && !isNamespaceMember(namespace.getId(), currentUserId, userNsRoles)) { + && !isNamespaceMember(namespace.getId(), currentUserId, userNsRoles) + && !isSuperAdmin(platformRoles)) { throw new DomainForbiddenException("error.namespace.archived", namespaceSlug); } - if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) { + if (skill.getStatus() != SkillStatus.ACTIVE + && !canManageRestrictedSkill(skill, currentUserId, userNsRoles)) { + throw new DomainForbiddenException("error.skill.access.denied", skillSlug); + } + + if (!visibilityChecker.canAccessForNamespaceRead( + skill, + currentUserId, + userNsRoles, + isSuperAdmin(platformRoles))) { throw new DomainForbiddenException("error.skill.access.denied", skillSlug); } @@ -251,15 +270,6 @@ public class SkillQueryService { ); } - public SkillDetailDTO getSkillDetail( - String namespaceSlug, - String skillSlug, - String currentUserId, - Map userNsRoles, - Set platformRoles) { - return getSkillDetail(namespaceSlug, skillSlug, currentUserId, userNsRoles); - } - /** * Lists skills within a namespace after filtering out records the caller is * not allowed to discover. @@ -296,9 +306,19 @@ public class SkillQueryService { String version, String currentUserId, Map userNsRoles) { + return getVersionDetail(namespaceSlug, skillSlug, version, currentUserId, userNsRoles, Set.of()); + } + + public SkillVersionDetailDTO getVersionDetail( + String namespaceSlug, + String skillSlug, + String version, + String currentUserId, + Map userNsRoles, + Set platformRoles) { Namespace namespace = findNamespace(namespaceSlug); Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); - assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); + assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles, platformRoles); SkillVersion skillVersion = findVersion(skill, version); assertPreviewAccessible(skill, skillVersion, version, currentUserId, userNsRoles); @@ -322,13 +342,32 @@ public class SkillQueryService { String toVersion, String currentUserId, Map userNsRoles) { + return compareVersions( + namespaceSlug, + skillSlug, + fromVersion, + toVersion, + currentUserId, + userNsRoles, + Set.of() + ); + } + + public SkillVersionCompareDTO compareVersions( + String namespaceSlug, + String skillSlug, + String fromVersion, + String toVersion, + String currentUserId, + Map userNsRoles, + Set platformRoles) { if (Objects.equals(fromVersion, toVersion)) { throw new DomainBadRequestException("error.skill.version.compare.same"); } Namespace namespace = findNamespace(namespaceSlug); Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); - assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); + assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles, platformRoles); SkillVersion from = findVersion(skill, fromVersion); SkillVersion to = findVersion(skill, toVersion); @@ -385,9 +424,19 @@ public class SkillQueryService { String version, String currentUserId, Map userNsRoles) { + return listFiles(namespaceSlug, skillSlug, version, currentUserId, userNsRoles, Set.of()); + } + + public List listFiles( + String namespaceSlug, + String skillSlug, + String version, + String currentUserId, + Map userNsRoles, + Set platformRoles) { Namespace namespace = findNamespace(namespaceSlug); Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); - assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); + assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles, platformRoles); SkillVersion skillVersion = findVersion(skill, version); assertPreviewAccessible(skill, skillVersion, version, currentUserId, userNsRoles); @@ -401,9 +450,19 @@ public class SkillQueryService { String tagName, String currentUserId, Map userNsRoles) { + return listFilesByTag(namespaceSlug, skillSlug, tagName, currentUserId, userNsRoles, Set.of()); + } + + public List listFilesByTag( + String namespaceSlug, + String skillSlug, + String tagName, + String currentUserId, + Map userNsRoles, + Set platformRoles) { Namespace namespace = findNamespace(namespaceSlug); Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); - assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); + assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles, platformRoles); SkillVersion skillVersion = resolveVersionEntity(skill, null, tagName, null); return availableFiles(skillVersion.getId()); } @@ -419,9 +478,20 @@ public class SkillQueryService { String filePath, String currentUserId, Map userNsRoles) { + return getFileContent(namespaceSlug, skillSlug, version, filePath, currentUserId, userNsRoles, Set.of()); + } + + public InputStream getFileContent( + String namespaceSlug, + String skillSlug, + String version, + String filePath, + String currentUserId, + Map userNsRoles, + Set platformRoles) { Namespace namespace = findNamespace(namespaceSlug); Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); - assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); + assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles, platformRoles); SkillVersion skillVersion = findVersion(skill, version); assertPreviewAccessible(skill, skillVersion, version, currentUserId, userNsRoles); @@ -438,9 +508,20 @@ public class SkillQueryService { String filePath, String currentUserId, Map userNsRoles) { + return getFileContentByTag(namespaceSlug, skillSlug, tagName, filePath, currentUserId, userNsRoles, Set.of()); + } + + public InputStream getFileContentByTag( + String namespaceSlug, + String skillSlug, + String tagName, + String filePath, + String currentUserId, + Map userNsRoles, + Set platformRoles) { Namespace namespace = findNamespace(namespaceSlug); Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); - assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); + assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles, platformRoles); SkillVersion skillVersion = resolveVersionEntity(skill, null, tagName, null); SkillFile file = findFile(skillVersion, filePath); return readFileContent(file); @@ -451,9 +532,18 @@ public class SkillQueryService { String currentUserId, Map userNsRoles, Pageable pageable) { + return listVersions(namespaceSlug, skillSlug, currentUserId, userNsRoles, pageable, Set.of()); + } + + public Page listVersions(String namespaceSlug, + String skillSlug, + String currentUserId, + Map userNsRoles, + Pageable pageable, + Set platformRoles) { Namespace namespace = findNamespace(namespaceSlug); Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); - assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); + assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles, platformRoles); List visibleVersions; if (canManageRestrictedSkill(skill, currentUserId, userNsRoles)) { visibleVersions = skillVersionRepository.findBySkillId(skill.getId()).stream() @@ -551,13 +641,25 @@ public class SkillQueryService { String hash, String currentUserId, Map userNsRoles) { + return resolveVersion(namespaceSlug, skillSlug, version, tag, hash, currentUserId, userNsRoles, Set.of()); + } + + public ResolvedVersionDTO resolveVersion( + String namespaceSlug, + String skillSlug, + String version, + String tag, + String hash, + String currentUserId, + Map userNsRoles, + Set platformRoles) { if (version != null && !version.isBlank() && tag != null && !tag.isBlank()) { throw new DomainBadRequestException("error.skill.resolve.versionTag.conflict"); } Namespace namespace = findNamespace(namespaceSlug); Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId); - assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); + assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles, platformRoles); SkillVersion resolved = resolveVersionEntity(skill, version, tag, hash); assertInstallableVersion(resolved, resolved.getVersion()); String fingerprint = computeFingerprint(resolved); @@ -811,7 +913,18 @@ public class SkillQueryService { Skill skill, String currentUserId, Map userNsRoles) { - if (namespace.getStatus() == NamespaceStatus.ARCHIVED && !isNamespaceMember(skill.getNamespaceId(), currentUserId, userNsRoles)) { + assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles, Set.of()); + } + + private void assertPublishedAccessible( + Namespace namespace, + Skill skill, + String currentUserId, + Map userNsRoles, + Set platformRoles) { + if (namespace.getStatus() == NamespaceStatus.ARCHIVED + && !isNamespaceMember(skill.getNamespaceId(), currentUserId, userNsRoles) + && !isSuperAdmin(platformRoles)) { throw new DomainForbiddenException("error.namespace.archived", namespace.getSlug()); } if (skill.getStatus() != SkillStatus.ACTIVE && !canManageRestrictedSkill(skill, currentUserId, userNsRoles)) { @@ -820,7 +933,11 @@ public class SkillQueryService { if (skill.isHidden() && !canManageRestrictedSkill(skill, currentUserId, userNsRoles)) { throw new DomainForbiddenException("error.skill.access.denied", skill.getSlug()); } - if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) { + if (!visibilityChecker.canAccessForNamespaceRead( + skill, + currentUserId, + userNsRoles, + isSuperAdmin(platformRoles))) { throw new DomainForbiddenException("error.skill.access.denied", skill.getSlug()); } } @@ -863,6 +980,10 @@ public class SkillQueryService { return currentUserId != null && skill.getOwnerId().equals(currentUserId); } + private boolean isSuperAdmin(Set platformRoles) { + return platformRoles != null && platformRoles.contains("SUPER_ADMIN"); + } + private boolean isNamespaceMember(Long namespaceId, String currentUserId, Map userNsRoles) { return currentUserId != null && userNsRoles.containsKey(namespaceId); } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/VisibilityCheckerTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/VisibilityCheckerTest.java index af26901a..79c2fbbc 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/VisibilityCheckerTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/VisibilityCheckerTest.java @@ -189,4 +189,24 @@ class VisibilityCheckerTest { boolean canAccess = checker.canAccess(privateSkill, OTHER_USER_ID, Map.of(), Set.of()); assertFalse(canAccess); } + + @Test + void testNamespaceReadOverrideAllowsOnlyPublishedNamespaceVisibleSkills() { + Skill archivedNamespaceOnlySkill = new Skill( + NAMESPACE_ID, "archived-namespace-skill", OWNER_ID, SkillVisibility.NAMESPACE_ONLY); + archivedNamespaceOnlySkill.setLatestVersionId(14L); + archivedNamespaceOnlySkill.setStatus(SkillStatus.ARCHIVED); + + assertTrue(checker.canAccessForNamespaceRead(namespaceOnlySkill, OTHER_USER_ID, Map.of(), true)); + assertFalse(checker.canAccessForNamespaceRead(privateSkill, OTHER_USER_ID, Map.of(), true)); + assertFalse(checker.canAccessForNamespaceRead(hiddenPublicSkill, OTHER_USER_ID, Map.of(), true)); + assertFalse(checker.canAccessForNamespaceRead(unpublishedPublicSkill, OTHER_USER_ID, Map.of(), true)); + assertFalse(checker.canAccessForNamespaceRead(archivedNamespaceOnlySkill, OTHER_USER_ID, Map.of(), true)); + } + + @Test + void testNamespaceReadOverrideDoesNotChangeOrdinaryAccessWhenDisabled() { + assertFalse(checker.canAccessForNamespaceRead(namespaceOnlySkill, OTHER_USER_ID, Map.of(), false)); + assertTrue(checker.canAccessForNamespaceRead(publicSkill, OTHER_USER_ID, Map.of(), false)); + } } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java index 89a2e4cb..0ee04ceb 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java @@ -25,6 +25,7 @@ import java.time.Instant; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.zip.ZipInputStream; import static org.junit.jupiter.api.Assertions.*; @@ -214,6 +215,56 @@ class SkillDownloadServiceTest { verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class)); } + @Test + void testDownloadLatest_ShouldAllowSuperAdminForArchivedNamespaceOnlySkillWithoutMembership() throws Exception { + String namespaceSlug = "archived"; + String skillSlug = "namespace-only-skill"; + Map userNsRoles = Map.of(); + Set platformRoles = Set.of("SUPER_ADMIN"); + + Namespace namespace = new Namespace(namespaceSlug, "Archived", "owner-1"); + setId(namespace, 1L); + namespace.setStatus(com.iflytek.skillhub.domain.namespace.NamespaceStatus.ARCHIVED); + Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.NAMESPACE_ONLY); + setId(skill, 1L); + skill.setDisplayName("Namespace Only Skill"); + skill.setStatus(SkillStatus.ACTIVE); + skill.setLatestVersionId(10L); + + SkillVersion version = new SkillVersion(1L, "1.0.0", "owner-1"); + setId(version, 10L); + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(true); + ObjectMetadata metadata = new ObjectMetadata(4L, "application/zip", Instant.now()); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + when(visibilityChecker.canAccessForNamespaceRead(skill, "super-1", userNsRoles, true)).thenReturn(true); + when(skillVersionRepository.findById(10L)).thenReturn(Optional.of(version)); + when(objectStorageService.exists("packages/1/10/bundle.zip")).thenReturn(true); + when(objectStorageService.getMetadata("packages/1/10/bundle.zip")).thenReturn(metadata); + when(objectStorageService.getObject("packages/1/10/bundle.zip")) + .thenReturn(new ByteArrayInputStream("test".getBytes())); + when(objectStorageService.generatePresignedUrl( + eq("packages/1/10/bundle.zip"), + any(), + eq("Namespace Only Skill-1.0.0.zip"))) + .thenReturn(null); + + SkillDownloadService.DownloadResult result = service.downloadLatest( + namespaceSlug, + skillSlug, + "super-1", + userNsRoles, + platformRoles + ); + + assertEquals("Namespace Only Skill-1.0.0.zip", result.filename()); + assertNotNull(result.openContent()); + verify(skillRepository).incrementDownloadCount(1L); + verify(skillVersionStatsRepository).incrementDownloadCount(10L, 1L); + } + @Test void testDownloadLatest_ShouldRejectAnonymousHiddenPrivateAndUnpublishedSkills() throws Exception { Namespace namespace = new Namespace("global", "Global", "owner-1"); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java index 02cf02f1..e75c9ee0 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java @@ -202,6 +202,63 @@ class SkillQueryServiceTest { service.getSkillDetail(namespaceSlug, skillSlug, null, Map.of())); } + @Test + void testGetSkillDetail_ShouldAllowSuperAdminToReadArchivedNamespaceOnlySkillWithoutMembership() throws Exception { + String namespaceSlug = "archived-team"; + String skillSlug = "namespace-only-skill"; + + Namespace namespace = new Namespace(namespaceSlug, "Archived Team", "owner-1"); + namespace.setStatus(NamespaceStatus.ARCHIVED); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.NAMESPACE_ONLY); + setId(skill, 1L); + skill.setLatestVersionId(11L); + + SkillVersion version = new SkillVersion(1L, "1.0.0", "owner-1"); + setId(version, 11L); + version.setStatus(SkillVersionStatus.PUBLISHED); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(version)); + when(userAccountRepository.findById("owner-1")).thenReturn(Optional.empty()); + + SkillQueryService.SkillDetailDTO result = service.getSkillDetail( + namespaceSlug, + skillSlug, + "super-1", + Map.of(), + Set.of("SUPER_ADMIN") + ); + + assertEquals(skillSlug, result.slug()); + assertFalse(result.canManageLifecycle()); + } + + @Test + void testGetSkillDetail_ShouldDenySuperAdminArchivedPublicSkillWithoutMembership() throws Exception { + String namespaceSlug = "active-team"; + String skillSlug = "archived-public-skill"; + + Namespace namespace = new Namespace(namespaceSlug, "Active Team", "owner-1"); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ARCHIVED); + skill.setLatestVersionId(11L); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + + assertThrows(DomainForbiddenException.class, () -> service.getSkillDetail( + namespaceSlug, + skillSlug, + "super-1", + Map.of(), + Set.of("SUPER_ADMIN") + )); + } + @Test void testListSkillsByNamespace() throws Exception { // Arrange @@ -561,6 +618,41 @@ class SkillQueryServiceTest { result.getContent().stream().map(SkillVersion::getVersion).toList()); } + @Test + void testListVersions_ShouldAllowSuperAdminReadWithoutGrantingLifecycleManagement() throws Exception { + String namespaceSlug = "archived-team"; + String skillSlug = "namespace-only-skill"; + + Namespace namespace = new Namespace(namespaceSlug, "Archived Team", "owner-1"); + namespace.setStatus(NamespaceStatus.ARCHIVED); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.NAMESPACE_ONLY); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + skill.setLatestVersionId(10L); + + SkillVersion published = new SkillVersion(1L, "1.0.0", "owner-1"); + setId(published, 10L); + published.setStatus(SkillVersionStatus.PUBLISHED); + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)) + .thenReturn(List.of(published)); + + Page result = service.listVersions( + namespaceSlug, + skillSlug, + "super-1", + Map.of(), + PageRequest.of(0, 20), + Set.of("SUPER_ADMIN") + ); + + assertEquals(List.of("1.0.0"), + result.getContent().stream().map(SkillVersion::getVersion).toList()); + verify(skillVersionRepository, never()).findBySkillId(1L); + } + @Test void testResolveVersion_ShouldReturnLatestWhenHashDoesNotMatch() throws Exception { String namespaceSlug = "test-ns"; diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java index 7e7f3db0..07fa8e94 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java @@ -3,9 +3,12 @@ package com.iflytek.skillhub.infra.jpa; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceStatus; +import com.iflytek.skillhub.domain.namespace.NamespaceType; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.List; @@ -20,4 +23,44 @@ public interface NamespaceJpaRepository List findByIdIn(List ids); Optional findBySlug(String slug); Page findByStatus(NamespaceStatus status, Pageable pageable); + + @Override + @Query(""" + SELECT n + FROM Namespace n + WHERE (:status IS NULL OR n.status = :status) + AND (:type IS NULL OR n.type = :type) + AND ( + :query IS NULL + OR lower(n.slug) LIKE lower(concat('%', cast(:query as string), '%')) ESCAPE '!' + OR lower(n.displayName) LIKE lower(concat('%', cast(:query as string), '%')) ESCAPE '!' + ) + AND (:slug IS NULL OR n.slug = :slug) + """) + Page search(@Param("status") NamespaceStatus status, + @Param("type") NamespaceType type, + @Param("query") String query, + @Param("slug") String slug, + Pageable pageable); + + @Override + @Query(""" + SELECT n + FROM Namespace n + WHERE n.id IN :ids + AND (:status IS NULL OR n.status = :status) + AND (:type IS NULL OR n.type = :type) + AND ( + :query IS NULL + OR lower(n.slug) LIKE lower(concat('%', cast(:query as string), '%')) ESCAPE '!' + OR lower(n.displayName) LIKE lower(concat('%', cast(:query as string), '%')) ESCAPE '!' + ) + AND (:slug IS NULL OR n.slug = :slug) + """) + Page searchByIdIn(@Param("ids") List ids, + @Param("status") NamespaceStatus status, + @Param("type") NamespaceType type, + @Param("query") String query, + @Param("slug") String slug, + Pageable pageable); } diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/event/SearchIndexEventListener.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/event/SearchIndexEventListener.java index b0fcee58..098e1231 100644 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/event/SearchIndexEventListener.java +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/event/SearchIndexEventListener.java @@ -2,6 +2,7 @@ package com.iflytek.skillhub.search.event; import com.iflytek.skillhub.domain.event.SkillPublishedEvent; import com.iflytek.skillhub.domain.event.SkillStatusChangedEvent; +import com.iflytek.skillhub.domain.event.SkillVersionYankedEvent; import com.iflytek.skillhub.domain.skill.SkillStatus; import com.iflytek.skillhub.search.SearchIndexService; import com.iflytek.skillhub.search.SearchRebuildService; @@ -32,6 +33,12 @@ public class SearchIndexEventListener { searchRebuildService.rebuildBySkill(event.skillId()); } + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Async("skillhubEventExecutor") + public void onSkillVersionYanked(SkillVersionYankedEvent event) { + searchRebuildService.rebuildBySkill(event.skillId()); + } + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) @Async("skillhubEventExecutor") public void onSkillStatusChanged(SkillStatusChangedEvent event) { 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 64015844..1e79f041 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 @@ -107,9 +107,7 @@ public class PostgresFullTextQueryService implements SearchQueryService { sql.append("FROM skill_search_document d "); sql.append("JOIN skill s ON s.id = d.skill_id "); sql.append("JOIN namespace n ON n.id = d.namespace_id "); - if (query.requireInstallableLatest()) { - sql.append("JOIN skill_version latest ON latest.id = s.latest_version_id "); - } + sql.append("JOIN skill_version latest ON latest.id = s.latest_version_id "); sql.append("WHERE 1=1 "); // Visibility filtering @@ -123,10 +121,10 @@ public class PostgresFullTextQueryService implements SearchQueryService { sql.append("AND d.status = 'ACTIVE' "); sql.append("AND s.status = 'ACTIVE' "); sql.append("AND s.hidden = FALSE "); + sql.append("AND latest.status = 'PUBLISHED' "); + sql.append("AND latest.yanked_at IS NULL "); if (query.requireInstallableLatest()) { - sql.append("AND latest.status = 'PUBLISHED' "); sql.append("AND latest.download_ready = TRUE "); - sql.append("AND latest.yanked_at IS NULL "); } sql.append("AND (n.status <> 'ARCHIVED' "); if (query.visibilityScope().userId() != null) { diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildService.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildService.java index 97e2d560..2f1efd38 100644 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildService.java +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildService.java @@ -14,6 +14,7 @@ import com.iflytek.skillhub.domain.skill.SkillRepository; import com.iflytek.skillhub.domain.skill.SkillStatus; import com.iflytek.skillhub.domain.skill.SkillVersion; import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; import com.iflytek.skillhub.search.SearchIndexService; import com.iflytek.skillhub.search.SearchRebuildService; import com.iflytek.skillhub.search.SearchTextTokenizer; @@ -92,11 +93,17 @@ public class PostgresSearchRebuildService implements SearchRebuildService { @Override public void rebuildAll() { - List documents = skillRepository.findAll().stream() - .filter(skill -> skill.getStatus() == SkillStatus.ACTIVE) - .map(this::toDocument) - .flatMap(Optional::stream) - .toList(); + List documents = new ArrayList<>(); + for (Skill skill : skillRepository.findAll()) { + Optional document = skill.getStatus() == SkillStatus.ACTIVE + ? toDocument(skill) + : Optional.empty(); + if (document.isPresent()) { + documents.add(document.get()); + } else { + searchIndexService.remove(skill.getId()); + } + } searchIndexService.batchIndex(documents); } @@ -116,18 +123,21 @@ public class PostgresSearchRebuildService implements SearchRebuildService { return; } - toDocument(skillOpt.get()).ifPresent(searchIndexService::index); + Optional document = toDocument(skillOpt.get()); + if (document.isPresent()) { + searchIndexService.index(document.get()); + } else { + searchIndexService.remove(skillId); + } } - private SearchIndexPayload buildSearchPayload(Skill skill) { + private SearchIndexPayload buildSearchPayload(Skill skill, SkillVersion latestVersion) { List searchParts = new ArrayList<>(); addPart(searchParts, skill.getSlug()); addPart(searchParts, skill.getSummary()); Set keywords = new TreeSet<>(); - resolveLatestVersion(skill) - .map(this::extractParsedMetadata) - .map(metadata -> metadata.get("frontmatter")) + Optional.ofNullable(extractParsedMetadata(latestVersion).get("frontmatter")) .map(this::asMap) .ifPresent(frontmatter -> appendFrontmatter(frontmatter, keywords, searchParts)); appendLabelKeywords(skill.getId(), keywords); @@ -138,11 +148,13 @@ public class PostgresSearchRebuildService implements SearchRebuildService { ); } - private Optional resolveLatestVersion(Skill skill) { + private Optional resolvePublishedLatestVersion(Skill skill) { if (skill.getLatestVersionId() == null) { return Optional.empty(); } - return skillVersionRepository.findById(skill.getLatestVersionId()); + return skillVersionRepository.findById(skill.getLatestVersionId()) + .filter(version -> version.getStatus() == SkillVersionStatus.PUBLISHED) + .filter(version -> version.getYankedAt() == null); } private Map extractParsedMetadata(SkillVersion version) { @@ -269,13 +281,17 @@ public class PostgresSearchRebuildService implements SearchRebuildService { } private Optional toDocument(Skill skill) { + Optional latestVersion = resolvePublishedLatestVersion(skill); + if (latestVersion.isEmpty()) { + return Optional.empty(); + } Optional namespaceOpt = namespaceRepository.findById(skill.getNamespaceId()); if (namespaceOpt.isEmpty()) { return Optional.empty(); } Namespace namespace = namespaceOpt.get(); - SearchIndexPayload payload = buildSearchPayload(skill); + SearchIndexPayload payload = buildSearchPayload(skill, latestVersion.get()); return Optional.of(new SkillSearchDocument( skill.getId(), diff --git a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/event/SearchIndexEventListenerTest.java b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/event/SearchIndexEventListenerTest.java index 84a2b295..5788136c 100644 --- a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/event/SearchIndexEventListenerTest.java +++ b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/event/SearchIndexEventListenerTest.java @@ -2,6 +2,7 @@ package com.iflytek.skillhub.search.event; import com.iflytek.skillhub.domain.event.SkillPublishedEvent; import com.iflytek.skillhub.domain.event.SkillStatusChangedEvent; +import com.iflytek.skillhub.domain.event.SkillVersionYankedEvent; import com.iflytek.skillhub.domain.skill.SkillStatus; import com.iflytek.skillhub.search.SearchIndexService; import com.iflytek.skillhub.search.SearchRebuildService; @@ -33,4 +34,14 @@ class SearchIndexEventListenerTest { verify(searchIndexService).remove(42L); } + + @Test + void yankedVersionShouldTriggerSkillRebuild() { + SearchRebuildService searchRebuildService = mock(SearchRebuildService.class); + SearchIndexService searchIndexService = mock(SearchIndexService.class); + SearchIndexEventListener listener = new SearchIndexEventListener(searchRebuildService, searchIndexService); + + listener.onSkillVersionYanked(new SkillVersionYankedEvent(42L, 100L, "admin-1")); + verify(searchRebuildService).rebuildBySkill(42L); + } } 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 c84c6cbd..cf73ba74 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 @@ -400,6 +400,41 @@ class PostgresFullTextQueryServiceTest { verify(countQuery, never()).setParameter(org.mockito.ArgumentMatchers.eq("memberNamespaceIds"), org.mockito.ArgumentMatchers.any()); } + @Test + void portalSearchShouldRequirePublishedNonYankedLatestWithoutDownloadReadiness() { + 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( + "demo", + null, + SearchVisibilityScope.anonymous(), + "newest", + 0, + 12, + List.of(), + false + )); + + ArgumentCaptor sqlCaptor = ArgumentCaptor.forClass(String.class); + verify(entityManager, org.mockito.Mockito.times(2)).createNativeQuery(sqlCaptor.capture()); + assertThat(sqlCaptor.getAllValues()).allSatisfy(sql -> assertThat(sql) + .contains("JOIN skill_version latest ON latest.id = s.latest_version_id") + .contains("AND latest.status = 'PUBLISHED'") + .contains("AND latest.yanked_at IS NULL") + .doesNotContain("latest.download_ready = TRUE")); + } + @Test void installableLatestFilterShouldApplyToSearchAndCountQueries() { EntityManager entityManager = mock(EntityManager.class); diff --git a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildServiceTest.java b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildServiceTest.java index 4abd1e63..dc8ceac1 100644 --- a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildServiceTest.java +++ b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildServiceTest.java @@ -13,6 +13,7 @@ import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillRepository; import com.iflytek.skillhub.domain.skill.SkillVersion; import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.search.SearchIndexService; import com.iflytek.skillhub.search.SearchTextTokenizer; @@ -21,16 +22,144 @@ import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import java.lang.reflect.Field; +import java.time.Instant; import java.util.List; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; class PostgresSearchRebuildServiceTest { + @Test + void rebuildAll_shouldRemoveDocumentWhenLatestVersionIsNotPublished() { + SkillRepository skillRepository = mock(SkillRepository.class); + NamespaceRepository namespaceRepository = mock(NamespaceRepository.class); + SkillVersionRepository skillVersionRepository = mock(SkillVersionRepository.class); + SearchIndexService searchIndexService = mock(SearchIndexService.class); + + Skill skill = new Skill(7L, "private-upload", "owner-1", SkillVisibility.PRIVATE); + setField(skill, "id", 42L); + skill.setLatestVersionId(99L); + + SkillVersion version = new SkillVersion(42L, "1.0.0", "owner-1"); + version.setStatus(SkillVersionStatus.UPLOADED); + + when(skillRepository.findAll()).thenReturn(List.of(skill)); + when(namespaceRepository.findById(7L)).thenReturn(Optional.of( + new Namespace("team-ai", "Team AI", "owner-1") + )); + when(skillVersionRepository.findById(99L)).thenReturn(Optional.of(version)); + + PostgresSearchRebuildService service = newService( + skillRepository, + namespaceRepository, + skillVersionRepository, + searchIndexService + ); + + service.rebuildAll(); + + verify(searchIndexService).remove(42L); + verify(searchIndexService).batchIndex(List.of()); + verify(searchIndexService, never()).index(any()); + } + + @Test + void rebuildBySkill_shouldRemoveDocumentWhenLatestVersionIsNotPublished() { + SkillRepository skillRepository = mock(SkillRepository.class); + NamespaceRepository namespaceRepository = mock(NamespaceRepository.class); + SkillVersionRepository skillVersionRepository = mock(SkillVersionRepository.class); + SearchIndexService searchIndexService = mock(SearchIndexService.class); + + Skill skill = new Skill(7L, "private-upload", "owner-1", SkillVisibility.PRIVATE); + setField(skill, "id", 42L); + skill.setLatestVersionId(99L); + + SkillVersion version = new SkillVersion(42L, "1.0.0", "owner-1"); + version.setStatus(SkillVersionStatus.UPLOADED); + + when(skillRepository.findById(42L)).thenReturn(Optional.of(skill)); + when(namespaceRepository.findById(7L)).thenReturn(Optional.of( + new Namespace("team-ai", "Team AI", "owner-1") + )); + when(skillVersionRepository.findById(99L)).thenReturn(Optional.of(version)); + + PostgresSearchRebuildService service = newService( + skillRepository, + namespaceRepository, + skillVersionRepository, + searchIndexService + ); + + service.rebuildBySkill(42L); + + verify(searchIndexService).remove(42L); + verify(searchIndexService, never()).index(any()); + } + + @Test + void rebuildBySkill_shouldRemoveDocumentWhenLatestPublishedVersionIsYanked() { + SkillRepository skillRepository = mock(SkillRepository.class); + NamespaceRepository namespaceRepository = mock(NamespaceRepository.class); + SkillVersionRepository skillVersionRepository = mock(SkillVersionRepository.class); + SearchIndexService searchIndexService = mock(SearchIndexService.class); + + Skill skill = new Skill(7L, "yanked", "owner-1", SkillVisibility.PUBLIC); + setField(skill, "id", 42L); + skill.setLatestVersionId(99L); + + SkillVersion version = new SkillVersion(42L, "1.0.0", "owner-1"); + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setYankedAt(Instant.parse("2026-07-28T00:00:00Z")); + + when(skillRepository.findById(42L)).thenReturn(Optional.of(skill)); + when(namespaceRepository.findById(7L)).thenReturn(Optional.of( + new Namespace("team-ai", "Team AI", "owner-1") + )); + when(skillVersionRepository.findById(99L)).thenReturn(Optional.of(version)); + + PostgresSearchRebuildService service = newService( + skillRepository, + namespaceRepository, + skillVersionRepository, + searchIndexService + ); + + service.rebuildBySkill(42L); + + verify(searchIndexService).remove(42L); + verify(searchIndexService, never()).index(any()); + } + + @Test + void rebuildBySkill_shouldRemoveDocumentWhenNoPublishedVersionExists() { + SkillRepository skillRepository = mock(SkillRepository.class); + NamespaceRepository namespaceRepository = mock(NamespaceRepository.class); + SkillVersionRepository skillVersionRepository = mock(SkillVersionRepository.class); + SearchIndexService searchIndexService = mock(SearchIndexService.class); + + Skill skill = new Skill(7L, "pending-only", "owner-1", SkillVisibility.NAMESPACE_ONLY); + setField(skill, "id", 42L); + when(skillRepository.findById(42L)).thenReturn(Optional.of(skill)); + + PostgresSearchRebuildService service = newService( + skillRepository, + namespaceRepository, + skillVersionRepository, + searchIndexService + ); + + service.rebuildBySkill(42L); + + verify(searchIndexService).remove(42L); + verify(searchIndexService, never()).index(any()); + } + @Test void rebuildBySkill_shouldIndexFrontmatterFieldsAndKeywordsWithoutBody() { SkillRepository skillRepository = mock(SkillRepository.class); @@ -46,6 +175,7 @@ class PostgresSearchRebuildServiceTest { Namespace namespace = new Namespace("team-ai", "Team AI", "owner-1"); SkillVersion version = new SkillVersion(1L, "1.2.0", "owner-1"); + version.setStatus(SkillVersionStatus.PUBLISHED); version.setParsedMetadataJson(""" { "name": "Smart Agent", @@ -116,6 +246,7 @@ class PostgresSearchRebuildServiceTest { Namespace namespace = new Namespace("team-ai", "Team AI", "owner-1"); SkillVersion latestVersion = new SkillVersion(1L, "1.3.0", "owner-1"); + latestVersion.setStatus(SkillVersionStatus.PUBLISHED); latestVersion.setParsedMetadataJson(""" { "name": "Smart Agent", @@ -173,6 +304,7 @@ class PostgresSearchRebuildServiceTest { Namespace namespace = new Namespace("team-ai", "Team AI", "owner-1"); SkillVersion version = new SkillVersion(1L, "1.4.0", "owner-1"); + version.setStatus(SkillVersionStatus.PUBLISHED); version.setParsedMetadataJson(""" { "name": "Smart Agent", @@ -231,6 +363,7 @@ class PostgresSearchRebuildServiceTest { Namespace namespace = new Namespace("team-ai", "Team AI", "owner-1"); SkillVersion version = new SkillVersion(1L, "1.5.0", "owner-1"); + version.setStatus(SkillVersionStatus.PUBLISHED); version.setParsedMetadataJson(""" { "name": "Smart Agent", @@ -283,6 +416,7 @@ class PostgresSearchRebuildServiceTest { Namespace namespace = new Namespace("team-ai", "Team AI", "owner-1"); SkillVersion version = new SkillVersion(1L, "1.6.0", "owner-1"); + version.setStatus(SkillVersionStatus.PUBLISHED); version.setParsedMetadataJson(""" { "frontmatter": { diff --git a/web/e2e/helpers/test-data-builder.ts b/web/e2e/helpers/test-data-builder.ts index 71cd9ebd..9e9ddf42 100644 --- a/web/e2e/helpers/test-data-builder.ts +++ b/web/e2e/helpers/test-data-builder.ts @@ -61,6 +61,7 @@ export interface SeedSkillOptions { name?: string description?: string version?: string + visibility?: 'PUBLIC' | 'NAMESPACE_ONLY' | 'PRIVATE' readmeHeading?: string readmeBody?: string extraFiles?: Array<{ @@ -575,7 +576,7 @@ export class E2eTestDataBuilder { mimeType: 'application/zip', buffer: zipBuffer, }, - visibility: 'PUBLIC', + visibility: options?.visibility ?? 'PUBLIC', }, headers: await csrfHeaders(this.page), }), diff --git a/web/e2e/my-namespaces-pagination.spec.ts b/web/e2e/my-namespaces-pagination.spec.ts new file mode 100644 index 00000000..eca0abab --- /dev/null +++ b/web/e2e/my-namespaces-pagination.spec.ts @@ -0,0 +1,140 @@ +import { expect, test, type Page, type Route } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' + +function apiEnvelope(data: unknown) { + return { + code: 0, + msg: 'success', + data, + timestamp: new Date().toISOString(), + requestId: 'e2e-my-namespaces-pagination', + } +} + +function namespaceForPage(page: number) { + return { + id: page + 1, + slug: `team-page-${page}`, + displayName: `Team Page ${page}`, + description: `Namespace page ${page}`, + type: 'TEAM', + status: 'ACTIVE', + createdAt: '2026-07-29T00:00:00Z', + updatedAt: '2026-07-29T00:00:00Z', + currentUserRole: 'OWNER', + immutable: false, + canFreeze: false, + canUnfreeze: false, + canArchive: false, + canRestore: false, + canDelete: page === 2, + } +} + +async function fulfillJson(route: Route, data: unknown) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(apiEnvelope(data)), + }) +} + +test.describe('My Namespaces pagination', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + await page.context().setExtraHTTPHeaders({ + 'X-Mock-User-Id': 'local-admin', + }) + }) + + test('backs up to the previous valid page after deleting the only item on the last page', async ({ page }) => { + let totalNamespaces = 41 + const requestedPages: number[] = [] + + await installDashboardMocks(page, requestedPages, () => totalNamespaces, () => { + totalNamespaces = 40 + }) + + await page.goto('/dashboard/namespaces') + await expect(page.getByRole('heading', { name: 'My Namespaces' })).toBeVisible() + await expect(page.getByText('@team-page-0')).toBeVisible() + + await page.getByRole('button', { name: 'Go to page 3' }).click() + await expect.poll(() => requestedPages).toContain(2) + await expect(page.getByText('@team-page-2')).toBeVisible() + + await page.getByTestId('delete-namespace-team-page-2').click() + await expect(page.getByTestId('namespace-action-dialog-delete')).toBeVisible() + await page.getByTestId('namespace-action-confirm-delete').click() + + await expect.poll(() => requestedPages.slice(-2)).toEqual([2, 1]) + await expect(page.getByText('@team-page-1')).toBeVisible() + await expect(page.getByText('@team-page-2')).toHaveCount(0) + }) +}) + +async function installDashboardMocks( + page: Page, + requestedPages: number[], + getTotalNamespaces: () => number, + deleteLastNamespace: () => void, +) { + await page.route('**/api/v1/auth/me', async (route) => { + await fulfillJson(route, { + userId: 'local-admin', + displayName: 'Local Admin', + email: 'local-admin@example.com', + avatarUrl: '', + oauthProvider: 'mock', + platformRoles: ['SUPER_ADMIN'], + }) + }) + + await page.route('**/api/v1/auth/providers**', async (route) => { + await fulfillJson(route, []) + }) + + await page.route('**/api/web/notifications/unread-count', async (route) => { + await fulfillJson(route, { count: 0 }) + }) + + await page.route('**/api/web/notifications/sse', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'text/event-stream', + body: '', + }) + }) + + await page.route('**/api/web/me/namespaces', async (route) => { + await fulfillJson(route, []) + }) + + await page.route('**/api/web/me/namespaces/page?**', async (route) => { + const url = new URL(route.request().url()) + const pageIndex = Number(url.searchParams.get('page') ?? '0') + const size = Number(url.searchParams.get('size') ?? '20') + const isPrimaryDashboardQuery = !url.searchParams.has('roles') + if (isPrimaryDashboardQuery) { + requestedPages.push(pageIndex) + } + + const total = getTotalNamespaces() + const isEmptiedLastPage = total === 40 && pageIndex === 2 + await fulfillJson(route, { + items: isEmptiedLastPage ? [] : [namespaceForPage(pageIndex)], + total, + page: pageIndex, + size, + }) + }) + + await page.route('**/api/web/namespaces/team-page-2', async (route) => { + if (route.request().method() === 'DELETE') { + deleteLastNamespace() + await fulfillJson(route, { message: 'Namespace deleted successfully' }) + return + } + await route.fallback() + }) +} diff --git a/web/e2e/my-namespaces-super-admin-actions.spec.ts b/web/e2e/my-namespaces-super-admin-actions.spec.ts new file mode 100644 index 00000000..07ce0bf1 --- /dev/null +++ b/web/e2e/my-namespaces-super-admin-actions.spec.ts @@ -0,0 +1,145 @@ +import { expect, test, type BrowserContext } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' +import { csrfHeaders } from './helpers/csrf' +import { E2eTestDataBuilder } from './helpers/test-data-builder' + +test.describe('My Namespaces super admin actions (Real API)', () => { + test.describe.configure({ timeout: 150_000 }) + + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + await page.context().setExtraHTTPHeaders({ + 'X-Mock-User-Id': 'local-admin', + }) + }) + + test('opens and downloads a published namespace-only skill in an archived non-member namespace', async ({ page, browser }, testInfo) => { + let adminBuilder: E2eTestDataBuilder | undefined + let ownerContext: BrowserContext | undefined + let ownerBuilder: E2eTestDataBuilder | undefined + let namespaceSlug: string | undefined + let namespaceArchived = false + + try { + adminBuilder = new E2eTestDataBuilder(page, testInfo) + await adminBuilder.init() + const activeNamespace = await adminBuilder.createNamespace('e2e-super-admin-publish-active') + const namespace = await adminBuilder.createNamespace('e2e-super-admin-read') + namespaceSlug = namespace.slug + + ownerContext = await browser.newContext({ + extraHTTPHeaders: { + 'X-Mock-User-Id': 'local-user', + }, + }) + const ownerPage = await ownerContext.newPage() + ownerBuilder = new E2eTestDataBuilder(ownerPage, testInfo) + await ownerBuilder.init() + + await adminBuilder.addNamespaceMember(namespace.slug, 'local-user') + const transferResponse = await page.context().request.post( + `/api/web/namespaces/${encodeURIComponent(namespace.slug)}/transfer-ownership`, + { + data: { newOwnerId: 'local-user' }, + headers: await csrfHeaders(page), + }, + ) + expect(transferResponse.ok()).toBe(true) + + const removeAdminResponse = await ownerPage.context().request.delete( + `/api/web/namespaces/${encodeURIComponent(namespace.slug)}/members/local-admin`, + { headers: await csrfHeaders(ownerPage) }, + ) + expect(removeAdminResponse.ok()).toBe(true) + + const skillName = `super-admin-read-${Date.now().toString(36)}` + const skill = await ownerBuilder.publishSkill(namespace.slug, { + name: skillName, + description: 'Published namespace-only skill for the SUPER_ADMIN read-chain regression', + visibility: 'NAMESPACE_ONLY', + readmeHeading: skillName, + }) + + const reviewTaskId = await adminBuilder.waitForPendingReview(namespace.slug, skill.slug, skill.version) + await adminBuilder.approveReview(reviewTaskId) + + const archiveResponse = await ownerPage.context().request.post( + `/api/web/namespaces/${encodeURIComponent(namespace.slug)}/archive`, + { + data: { reason: 'Validate SUPER_ADMIN archived namespace reads' }, + headers: await csrfHeaders(ownerPage), + }, + ) + expect(archiveResponse.ok()).toBe(true) + namespaceArchived = true + + await page.goto('/dashboard/publish') + const namespaceTrigger = page.locator('#namespace') + await namespaceTrigger.click() + await page.getByRole('searchbox', { name: 'Search namespaces' }).fill(activeNamespace.slug) + const activeOption = page.getByRole('button', { + name: `${activeNamespace.displayName} (@${activeNamespace.slug})`, + }) + await expect(activeOption).toBeVisible() + await activeOption.click() + + await expect(namespaceTrigger).toContainText(`@${activeNamespace.slug}`) + await namespaceTrigger.click() + await page.getByRole('searchbox', { name: 'Search namespaces' }).fill(namespace.slug) + await expect(page.getByText('No namespaces found')).toBeVisible() + await expect(page.getByRole('button', { + name: `${namespace.displayName} (@${namespace.slug})`, + })).toHaveCount(0) + + await page.goto(`/dashboard/publish?namespace=${encodeURIComponent(namespace.slug)}&visibility=PUBLIC`) + await expect(namespaceTrigger).toContainText(`@${namespace.slug}`) + await expect(page.getByText('The selected namespace is not active or is no longer available.')).toBeVisible() + + await page.goto('/dashboard/namespaces') + + const namespaceCard = page.getByTestId(`namespace-card-${namespace.slug}`) + await expect(namespaceCard.getByText(`@${namespace.slug}`)).toBeVisible() + await expect(namespaceCard.getByText('Current role: Unknown')).toBeVisible() + await expect(namespaceCard.getByRole('button', { name: 'Manage Members' })).toHaveCount(0) + await expect(namespaceCard.getByRole('button', { name: 'Review Tasks' })).toHaveCount(0) + + await namespaceCard.click() + await expect(page).toHaveURL(new RegExp(`/space/${namespace.slug}$`)) + await expect(page.getByRole('heading', { name: namespace.displayName })).toBeVisible() + + const skillHeading = page.getByRole('heading', { name: skillName, exact: true }) + await expect(skillHeading).toBeVisible() + await skillHeading.click() + + await expect(page).toHaveURL(new RegExp(`/space/${namespace.slug}/${skill.slug}$`)) + await expect(page.getByRole('heading', { name: skillName, exact: true }).first()).toBeVisible() + + const downloadPromise = page.waitForEvent('download') + await page.getByRole('button', { name: 'Download', exact: true }).click() + const download = await downloadPromise + expect(await download.failure()).toBeNull() + expect(download.suggestedFilename()).toContain(skill.version) + } finally { + if (namespaceArchived && namespaceSlug && ownerContext) { + const ownerPage = ownerContext.pages()[0] + await ownerPage.context().request.post( + `/api/web/namespaces/${encodeURIComponent(namespaceSlug)}/restore`, + { headers: await csrfHeaders(ownerPage) }, + ).catch(() => undefined) + } + await ownerBuilder?.cleanup() + if (namespaceSlug && ownerContext) { + const ownerPage = ownerContext.pages()[0] + await ownerPage.context().request.post( + `/api/web/namespaces/${encodeURIComponent(namespaceSlug)}/archive`, + { + data: { reason: 'E2E cleanup' }, + headers: await csrfHeaders(ownerPage), + }, + ).catch(() => undefined) + } + await adminBuilder?.cleanup() + await ownerContext?.close() + } + }) +}) diff --git a/web/e2e/promotions-review.spec.ts b/web/e2e/promotions-review.spec.ts index af7d98a2..8030635b 100644 --- a/web/e2e/promotions-review.spec.ts +++ b/web/e2e/promotions-review.spec.ts @@ -74,14 +74,19 @@ test.describe('Promotion review dashboard', () => { }), }) }) - await page.route('**/api/web/me/namespaces', async (route) => { + await page.route('**/api/web/me/namespaces/page?**', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 0, msg: 'success', - data: [], + data: { + items: [], + total: 0, + page: 0, + size: 20, + }, timestamp: new Date().toISOString(), requestId: 'e2e-namespaces', }), diff --git a/web/e2e/publish-flow-ui.spec.ts b/web/e2e/publish-flow-ui.spec.ts index 867c8248..d1e325ce 100644 --- a/web/e2e/publish-flow-ui.spec.ts +++ b/web/e2e/publish-flow-ui.spec.ts @@ -35,7 +35,7 @@ test.describe('Publish Flow UI (Real API)', () => { const namespaceTrigger = page.locator('#namespace') await expect(namespaceTrigger).toBeVisible() await namespaceTrigger.click() - const namespaceOption = page.getByRole('option', { + const namespaceOption = page.getByRole('dialog').getByRole('button', { name: new RegExp(`\\(@${namespace.slug}\\)`), }).first() await expect(namespaceOption).toBeVisible() diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index da659024..4223dc2f 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -172,6 +172,115 @@ describe('namespaceApi.delete', () => { }) }) +describe('namespaceApi.listMine', () => { + it('keeps the compatibility endpoint as a current user namespace array', async () => { + window.__SKILLHUB_RUNTIME_CONFIG__ = { apiBaseUrl: 'https://api.example.com' } + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + code: 0, + msg: 'ok', + data: [{ + id: 1, + slug: 'team-a', + displayName: 'Team A', + type: 'TEAM', + status: 'ACTIVE', + createdAt: '2026-05-07T00:00:00Z', + immutable: false, + canFreeze: false, + canUnfreeze: false, + canArchive: false, + canRestore: false, + canDelete: false, + }], + timestamp: '2026-05-07T00:00:00Z', + requestId: 'req-test', + }), + }) + vi.stubGlobal('fetch', fetchMock) + + const namespaces = await namespaceApi.listMine() + + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.example.com/api/web/me/namespaces', + expect.objectContaining({ + headers: expect.any(Headers), + }), + ) + expect(namespaces).toEqual([expect.objectContaining({ slug: 'team-a' })]) + }) +}) + +describe('namespaceApi.listMinePage', () => { + it('requests a bounded page of current user namespaces', async () => { + window.__SKILLHUB_RUNTIME_CONFIG__ = { apiBaseUrl: 'https://api.example.com' } + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + code: 0, + msg: 'ok', + data: { + items: [], + total: 0, + page: 2, + size: 25, + }, + timestamp: '2026-05-07T00:00:00Z', + requestId: 'req-test', + }), + }) + vi.stubGlobal('fetch', fetchMock) + + const page = await namespaceApi.listMinePage({ page: 2, size: 25 }) + + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.example.com/api/web/me/namespaces/page?page=2&size=25', + expect.objectContaining({ + headers: expect.any(Headers), + }), + ) + expect(page).toEqual({ + items: [], + total: 0, + page: 2, + size: 25, + }) + }) + + it('encodes namespace filters without issuing an unbounded request', async () => { + window.__SKILLHUB_RUNTIME_CONFIG__ = { apiBaseUrl: 'https://api.example.com' } + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + code: 0, + msg: 'ok', + data: { items: [], total: 0, page: 1, size: 20 }, + timestamp: '2026-05-07T00:00:00Z', + requestId: 'req-filtered', + }), + }) + vi.stubGlobal('fetch', fetchMock) + + await namespaceApi.listMinePage({ + page: 1, + size: 20, + status: 'ACTIVE', + type: 'TEAM', + q: 'team ai', + slug: 'team-ai', + sort: ['slug,desc'], + roles: ['OWNER', 'ADMIN'], + }) + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.example.com/api/web/me/namespaces/page?page=1&size=20&sort=slug%2Cdesc&status=ACTIVE&type=TEAM&q=team+ai&slug=team-ai&roles=OWNER&roles=ADMIN', + expect.objectContaining({ headers: expect.any(Headers) }), + ) + }) +}) + describe('getDirectAuthRuntimeConfig', () => { it('returns disabled when no runtime config is present', () => { const config = getDirectAuthRuntimeConfig() diff --git a/web/src/api/client.ts b/web/src/api/client.ts index b0fe1a2e..e2528284 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -49,6 +49,22 @@ import type { import { ApiError } from '@/shared/lib/api-error' import i18n from '@/i18n/config' +type OperationQuery = Operation extends { parameters: { query?: infer Query } } ? NonNullable : never +type OperationData = Operation extends { + responses: { + 200: { + content: { + '*/*': infer Envelope + } + } + } +} ? Envelope extends { data?: infer Data } ? NonNullable : never : never + +type ListMyNamespacesPageOperation = paths['/api/web/me/namespaces/page']['get'] + +export type MyNamespacePageParams = OperationQuery +export type MyNamespacePageResponse = OperationData & PagedResponse + /** * Front-end API foundation for generated OpenAPI calls and hand-written convenience wrappers. * @@ -657,6 +673,27 @@ export const namespaceApi = { return fetchJson(`${WEB_API_PREFIX}/me/namespaces`) }, + async listMinePage(params: MyNamespacePageParams = {}): Promise { + const page = params.page ?? 0 + const size = params.size ?? 20 + const query = new URLSearchParams({ page: String(page), size: String(size) }) + params.sort?.forEach((sort) => query.append('sort', sort)) + if (params.status) { + query.set('status', params.status) + } + if (params.type) { + query.set('type', params.type) + } + if (params.q?.trim()) { + query.set('q', params.q.trim()) + } + if (params.slug?.trim()) { + query.set('slug', normalizeNamespaceSlug(params.slug)) + } + params.roles?.forEach((role) => query.append('roles', role)) + return fetchJson(`${WEB_API_PREFIX}/me/namespaces/page?${query.toString()}`) + }, + async getDetail(slug: string): Promise { return fetchJson(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}`) }, diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index a99ba1a9..2976e116 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -2660,6 +2660,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/me/namespaces/page": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listMyNamespacesPage"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/me/namespaces/page": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listMyNamespacesPage_1"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/me/namespaces": { parameters: { query?: never; @@ -4514,11 +4546,11 @@ export interface components { /** Format: int32 */ size?: number; }; - ApiResponseListMyNamespaceResponse: { + ApiResponsePageResponseMyNamespaceResponse: { /** Format: int32 */ code?: number; msg?: string; - data?: components["schemas"]["MyNamespaceResponse"][]; + data?: components["schemas"]["PageResponseMyNamespaceResponse"]; /** Format: date-time */ timestamp?: string; requestId?: string; @@ -4548,6 +4580,24 @@ export interface components { canRestore?: boolean; canDelete?: boolean; }; + PageResponseMyNamespaceResponse: { + items?: components["schemas"]["MyNamespaceResponse"][]; + /** Format: int64 */ + total?: number; + /** Format: int32 */ + page?: number; + /** Format: int32 */ + size?: number; + }; + ApiResponseListMyNamespaceResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["MyNamespaceResponse"][]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; ApiResponseGovernanceSummaryResponse: { /** Format: int32 */ code?: number; @@ -9999,6 +10049,76 @@ export interface operations { }; }; }; + listMyNamespacesPage: { + parameters: { + query?: { + /** @description Zero-based page index. */ + page?: number; + /** @description Page size. Values above the namespace list limit are bounded by the backend. */ + size?: number; + /** + * @description Sort criteria in property,direction form. Only slug sorting is honored; defaults to slug,asc. + * @example slug,asc + */ + sort?: string[]; + status?: "ACTIVE" | "FROZEN" | "ARCHIVED"; + type?: "GLOBAL" | "TEAM"; + q?: string; + slug?: string; + roles?: ("OWNER" | "ADMIN" | "MEMBER")[]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePageResponseMyNamespaceResponse"]; + }; + }; + }; + }; + listMyNamespacesPage_1: { + parameters: { + query?: { + /** @description Zero-based page index. */ + page?: number; + /** @description Page size. Values above the namespace list limit are bounded by the backend. */ + size?: number; + /** + * @description Sort criteria in property,direction form. Only slug sorting is honored; defaults to slug,asc. + * @example slug,asc + */ + sort?: string[]; + status?: "ACTIVE" | "FROZEN" | "ARCHIVED"; + type?: "GLOBAL" | "TEAM"; + q?: string; + slug?: string; + roles?: ("OWNER" | "ADMIN" | "MEMBER")[]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePageResponseMyNamespaceResponse"]; + }; + }; + }; + }; listMyNamespaces: { parameters: { query?: never; diff --git a/web/src/features/namespace/use-my-namespaces.test.ts b/web/src/features/namespace/use-my-namespaces.test.ts deleted file mode 100644 index ee36c691..00000000 --- a/web/src/features/namespace/use-my-namespaces.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, expect, it } from 'vitest' -import * as mod from './use-my-namespaces' - -/** - * use-my-namespaces.ts is a feature-local re-export of the - * useMyNamespaces hook from the shared query layer. There is no custom - * logic, transformation, or query-key function to test. - * - * We verify the re-export contract so import paths used by namespace - * dashboard screens break fast if the module shape changes. - */ -describe('use-my-namespaces re-export', () => { - it('re-exports useMyNamespaces as a function', () => { - expect(mod.useMyNamespaces).toBeDefined() - expect(typeof mod.useMyNamespaces).toBe('function') - }) -}) diff --git a/web/src/features/namespace/use-my-namespaces.ts b/web/src/features/namespace/use-my-namespaces.ts deleted file mode 100644 index 6332cb17..00000000 --- a/web/src/features/namespace/use-my-namespaces.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * Preserves a feature-local import path for dashboard namespace screens while - * the underlying query implementation still lives in the shared hook module. - */ -export { useMyNamespaces } from '@/shared/hooks/use-namespace-queries' diff --git a/web/src/features/review/use-namespace-review-entry.test.ts b/web/src/features/review/use-namespace-review-entry.test.ts new file mode 100644 index 00000000..228abac5 --- /dev/null +++ b/web/src/features/review/use-namespace-review-entry.test.ts @@ -0,0 +1,103 @@ +import type { ManagedNamespace } from '@/api/types' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const useMyNamespacesPageMock = vi.hoisted(() => vi.fn()) + +vi.mock('@/shared/hooks/use-namespace-queries', () => ({ + useMyNamespacesPage: (...args: unknown[]) => useMyNamespacesPageMock(...args), +})) + +import { useNamespaceReviewEntry } from './use-namespace-review-entry' + +function namespace(slug: string, status: ManagedNamespace['status']): ManagedNamespace { + return { + id: slug.length, + slug, + displayName: slug, + type: 'TEAM', + status, + immutable: false, + canFreeze: false, + canUnfreeze: false, + canArchive: false, + canRestore: false, + canDelete: false, + currentUserRole: 'ADMIN', + createdAt: '', + } +} + +describe('useNamespaceReviewEntry', () => { + beforeEach(() => { + useMyNamespacesPageMock.mockReset() + }) + + it('uses one bounded ACTIVE query when an active review namespace exists', () => { + const active = namespace('zeta-active', 'ACTIVE') + const global = { ...namespace('global', 'ACTIVE'), type: 'GLOBAL' as const } + useMyNamespacesPageMock.mockImplementation((params: { status?: string; type?: string }) => ({ + data: { + items: params.type !== 'TEAM' + ? [global] + : params.status === 'ACTIVE' + ? [active] + : [namespace('alpha-archived', 'ARCHIVED')], + total: 1, + page: 0, + size: 1, + }, + isLoading: false, + error: null, + })) + + const result = useNamespaceReviewEntry(false) + + expect(useMyNamespacesPageMock).toHaveBeenNthCalledWith(1, { + page: 0, + size: 1, + status: 'ACTIVE', + type: 'TEAM', + roles: ['OWNER', 'ADMIN'], + }, true) + expect(useMyNamespacesPageMock).toHaveBeenNthCalledWith(2, { + page: 0, + size: 1, + type: 'TEAM', + roles: ['OWNER', 'ADMIN'], + }, false) + expect(result.namespaceReviewEntry?.slug).toBe('zeta-active') + }) + + it('falls back to one bounded any-status query when no ACTIVE namespace exists', () => { + const archived = namespace('alpha-archived', 'ARCHIVED') + useMyNamespacesPageMock.mockImplementation((params: { status?: string }) => ({ + data: { + items: params.status === 'ACTIVE' ? [] : [archived], + total: params.status === 'ACTIVE' ? 0 : 1, + page: 0, + size: 1, + }, + isLoading: false, + error: null, + })) + + const result = useNamespaceReviewEntry(false) + + expect(useMyNamespacesPageMock).toHaveBeenNthCalledWith(2, { + page: 0, + size: 1, + type: 'TEAM', + roles: ['OWNER', 'ADMIN'], + }, true) + expect(result.namespaceReviewEntry?.slug).toBe('alpha-archived') + }) + + it('disables both namespace queries for global reviewers', () => { + useMyNamespacesPageMock.mockReturnValue({ data: undefined, isLoading: false, error: null }) + + const result = useNamespaceReviewEntry(true) + + expect(useMyNamespacesPageMock.mock.calls.every((call) => call[1] === false)).toBe(true) + expect(result.namespaceReviewEntry).toBeNull() + }) +}) diff --git a/web/src/features/review/use-namespace-review-entry.ts b/web/src/features/review/use-namespace-review-entry.ts new file mode 100644 index 00000000..f797f293 --- /dev/null +++ b/web/src/features/review/use-namespace-review-entry.ts @@ -0,0 +1,43 @@ +import { useMyNamespacesPage } from '@/shared/hooks/use-namespace-queries' +import { getPreferredNamespaceReviewEntry } from './review-paths' + +const REVIEW_ROLES = ['OWNER', 'ADMIN'] as const + +/** + * Resolves a review namespace with at most two one-row requests: an ACTIVE + * namespace first, then any manageable namespace as a read-only fallback. + */ +export function useNamespaceReviewEntry(hasGlobalReviewAccess: boolean) { + const activeQuery = useMyNamespacesPage({ + page: 0, + size: 1, + status: 'ACTIVE', + type: 'TEAM', + roles: [...REVIEW_ROLES], + }, !hasGlobalReviewAccess) + const activeEntry = getPreferredNamespaceReviewEntry(activeQuery.data?.items) + const fallbackEnabled = !hasGlobalReviewAccess + && !activeQuery.isLoading + && !activeQuery.error + && activeQuery.data !== undefined + && activeEntry === null + const fallbackQuery = useMyNamespacesPage({ + page: 0, + size: 1, + type: 'TEAM', + roles: [...REVIEW_ROLES], + }, fallbackEnabled) + const fallbackEntry = fallbackEnabled + ? getPreferredNamespaceReviewEntry(fallbackQuery.data?.items) + : null + + return { + namespaceReviewEntry: activeEntry ?? fallbackEntry, + isLoadingNamespaces: !hasGlobalReviewAccess + && (activeQuery.isLoading || (fallbackEnabled && fallbackQuery.isLoading)), + hasNamespaceQueryError: Boolean(activeQuery.error || (fallbackEnabled && fallbackQuery.error)), + retryNamespaceQueries: () => fallbackEnabled + ? fallbackQuery.refetch() + : activeQuery.refetch(), + } +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 44a66da4..b8922c22 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -403,6 +403,20 @@ }, "publishSkill": "Publish Skill" }, + "namespacePicker": { + "placeholder": "Select namespace", + "title": "Select namespace", + "description": "Search or browse namespaces without loading the entire registry.", + "search": "Search namespaces", + "searchPlaceholder": "Search by slug or display name", + "loading": "Loading namespaces...", + "empty": "No namespaces found", + "error": "Failed to load namespaces", + "retry": "Retry", + "previous": "Previous", + "next": "Next", + "page": "Page {{current}} of {{total}}" + }, "myNamespaces": { "title": "My Namespaces", "subtitle": "Manage your namespaces and teams", @@ -478,6 +492,9 @@ "pageSubtitle": "Manage access credentials for CLI and API" }, "reviews": { + "namespaceLoadError": "Failed to load namespace review access.", + "loadingNamespaceAccess": "Loading namespace review access...", + "retryNamespaceLoad": "Retry", "title": "Review Center", "subtitle": "Manage platform review tasks", "typeSkill": "Skill Reviews", @@ -790,7 +807,9 @@ "notFound": "Namespace not found", "skillList": "Skills", "emptyTitle": "No skills", - "emptyDescription": "No skills have been published in this namespace yet" + "emptyDescription": "No skills have been published in this namespace yet", + "skillListErrorTitle": "Unable to load skills", + "skillListErrorDescription": "The namespace exists, but its skill list could not be loaded. Please try again later." }, "skillDetail": { "back": "Back", @@ -1348,6 +1367,9 @@ }, "namespace": "Namespace", "selectNamespace": "Select namespace", + "namespaceUnavailable": "The selected namespace is not active or is no longer available.", + "namespaceValidationError": "Failed to validate the selected namespace.", + "retryNamespaceValidation": "Retry validation", "visibility": "Visibility", "visibilityOptions": { "public": "Public", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index a707ad3d..e575edf7 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -403,6 +403,20 @@ }, "publishSkill": "发布技能" }, + "namespacePicker": { + "placeholder": "选择命名空间", + "title": "选择命名空间", + "description": "通过搜索或分页浏览命名空间,无需加载全部数据。", + "search": "搜索命名空间", + "searchPlaceholder": "按标识或显示名称搜索", + "loading": "正在加载命名空间...", + "empty": "未找到命名空间", + "error": "命名空间加载失败", + "retry": "重试", + "previous": "上一页", + "next": "下一页", + "page": "第 {{current}} / {{total}} 页" + }, "myNamespaces": { "title": "我的命名空间", "subtitle": "管理你的命名空间和团队", @@ -478,6 +492,9 @@ "pageSubtitle": "管理 CLI 和 API 使用的访问凭证" }, "reviews": { + "namespaceLoadError": "无法加载命名空间审核权限。", + "loadingNamespaceAccess": "正在加载命名空间审核权限...", + "retryNamespaceLoad": "重试", "title": "审核中心", "subtitle": "管理平台审核事务", "typeSkill": "技能审核", @@ -790,7 +807,9 @@ "notFound": "命名空间不存在", "skillList": "技能列表", "emptyTitle": "暂无技能", - "emptyDescription": "该命名空间下还没有发布任何技能" + "emptyDescription": "该命名空间下还没有发布任何技能", + "skillListErrorTitle": "无法加载技能列表", + "skillListErrorDescription": "命名空间存在,但技能列表暂时无法加载,请稍后重试。" }, "skillDetail": { "back": "返回上一页", @@ -1349,6 +1368,9 @@ }, "namespace": "命名空间", "selectNamespace": "选择命名空间", + "namespaceUnavailable": "所选命名空间未启用或已不可用。", + "namespaceValidationError": "无法校验所选命名空间。", + "retryNamespaceValidation": "重新校验", "visibility": "可见性", "visibilityOptions": { "public": "公开", diff --git a/web/src/pages/dashboard/my-namespaces.test.ts b/web/src/pages/dashboard/my-namespaces.test.ts index f3044a6c..8277cd1b 100644 --- a/web/src/pages/dashboard/my-namespaces.test.ts +++ b/web/src/pages/dashboard/my-namespaces.test.ts @@ -12,6 +12,13 @@ const restoreMutateAsync = vi.fn() const deleteMutateAsync = vi.fn() let mockNamespaces: ManagedNamespace[] = [] +let mockNamespacePage = { + items: [] as ManagedNamespace[], + total: 0, + page: 0, + size: 20, +} +let mockPlatformRoles: string[] = [] vi.mock('@tanstack/react-router', () => ({ useNavigate: () => navigateMock, @@ -28,7 +35,7 @@ vi.mock('react-i18next', async () => { }) vi.mock('@/features/auth/use-auth', () => ({ - useAuth: () => ({ hasRole: () => false }), + useAuth: () => ({ hasRole: (role: string) => mockPlatformRoles.includes(role) }), })) vi.mock('@/shared/ui/button', () => ({ @@ -75,7 +82,17 @@ vi.mock('@/shared/hooks/use-namespace-queries', () => ({ useArchiveNamespace: () => ({ mutateAsync: archiveMutateAsync }), useDeleteNamespace: () => ({ mutateAsync: deleteMutateAsync }), useFreezeNamespace: () => ({ mutateAsync: freezeMutateAsync }), - useMyNamespaces: () => ({ data: mockNamespaces, isLoading: false }), + useMyNamespacesPage: () => ({ + data: mockNamespacePage.total > 0 || mockNamespacePage.items.length > 0 + ? mockNamespacePage + : { + items: mockNamespaces, + total: mockNamespaces.length, + page: 0, + size: 20, + }, + isLoading: false, + }), useRestoreNamespace: () => ({ mutateAsync: restoreMutateAsync }), useUnfreezeNamespace: () => ({ mutateAsync: unfreezeMutateAsync }), })) @@ -85,7 +102,7 @@ vi.mock('@/shared/lib/toast', () => ({ })) import { MyNamespacesPage } from './my-namespaces' -import { executeNamespaceAction, resolveNamespaceActionCopy } from './my-namespaces' +import { executeNamespaceAction, resolveNamespaceActionCopy, resolveValidNamespacePage } from './my-namespaces' function buildNamespace(overrides: Partial = {}): ManagedNamespace { return { @@ -115,6 +132,13 @@ describe('MyNamespacesPage', () => { restoreMutateAsync.mockReset() deleteMutateAsync.mockReset() mockNamespaces = [] + mockNamespacePage = { + items: mockNamespaces, + total: 0, + page: 0, + size: 20, + } + mockPlatformRoles = [] }) it('exports a named component function', () => { @@ -137,6 +161,55 @@ describe('MyNamespacesPage', () => { expect(html).not.toContain('myNamespaces.delete') }) + it('hides namespace-scoped actions for super admins without namespace membership', () => { + mockPlatformRoles = ['SUPER_ADMIN'] + mockNamespaces = [buildNamespace({ currentUserRole: undefined })] + + const html = renderToStaticMarkup(createElement(MyNamespacesPage)) + + expect(html).toContain('Team ML') + expect(html).toContain('myNamespaces.roleUnknown') + expect(html).not.toContain('myNamespaces.manageMembers') + expect(html).not.toContain('myNamespaces.reviewTasks') + }) + + it('shows namespace-scoped actions for namespace members', () => { + mockNamespaces = [buildNamespace({ currentUserRole: 'ADMIN' })] + mockNamespacePage = { + items: mockNamespaces, + total: 1, + page: 0, + size: 20, + } + + const html = renderToStaticMarkup(createElement(MyNamespacesPage)) + + expect(html).toContain('myNamespaces.manageMembers') + expect(html).toContain('myNamespaces.reviewTasks') + }) + + it('renders pagination when more namespaces exist than the current page contains', () => { + mockNamespaces = [buildNamespace({ id: 1, slug: 'team-a', displayName: 'Team A' })] + mockNamespacePage = { + items: mockNamespaces, + total: 41, + page: 1, + size: 20, + } + + const html = renderToStaticMarkup(createElement(MyNamespacesPage)) + + expect(html).toContain('Team A') + expect(html).toContain('pagination.prev') + expect(html).toContain('pagination.next') + }) + + it('backs up to the last valid page when a delete empties the current page', () => { + expect(resolveValidNamespacePage(2, 40, 20)).toBe(1) + expect(resolveValidNamespacePage(1, 40, 20)).toBe(1) + expect(resolveValidNamespacePage(1, 0, 20)).toBe(0) + }) + it('routes delete actions to the delete mutation and emits success feedback', async () => { const t = (key: string) => key const copy = resolveNamespaceActionCopy(t, 'delete', 'Team ML') diff --git a/web/src/pages/dashboard/my-namespaces.tsx b/web/src/pages/dashboard/my-namespaces.tsx index 235f0b11..18a30fa8 100644 --- a/web/src/pages/dashboard/my-namespaces.tsx +++ b/web/src/pages/dashboard/my-namespaces.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import { useNavigate } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { useAuth } from '@/features/auth/use-auth' @@ -8,17 +8,20 @@ import { NamespaceBadge } from '@/shared/components/namespace-badge' import { EmptyState } from '@/shared/components/empty-state' import { ConfirmDialog } from '@/shared/components/confirm-dialog' import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' +import { Pagination } from '@/shared/components/pagination' import { CreateNamespaceDialog } from '@/features/namespace/create-namespace-dialog' import { useArchiveNamespace, useDeleteNamespace, useFreezeNamespace, - useMyNamespaces, + useMyNamespacesPage, useRestoreNamespace, useUnfreezeNamespace, } from '@/shared/hooks/use-namespace-queries' import { toast } from '@/shared/lib/toast' +const PAGE_SIZE = 20 + type PendingNamespaceAction = | { action: 'freeze'; slug: string; name: string } | { action: 'unfreeze'; slug: string; name: string } @@ -139,6 +142,12 @@ export async function executeNamespaceAction( } } +export function resolveValidNamespacePage(currentPage: number, total: number, size: number) { + const safeSize = Math.max(size, 1) + const lastPage = Math.max(Math.ceil(total / safeSize) - 1, 0) + return Math.min(Math.max(currentPage, 0), lastPage) +} + /** * Dashboard page for namespaces the current user can manage or review. It owns * namespace lifecycle actions because each action combines permissions, copy, @@ -149,13 +158,26 @@ export function MyNamespacesPage() { const { t } = useTranslation() const { hasRole } = useAuth() const canCreateNamespace = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN') + const [page, setPage] = useState(0) const [pendingAction, setPendingAction] = useState(null) - const { data: namespaces, isLoading } = useMyNamespaces() + const { data: namespacePage, isLoading } = useMyNamespacesPage({ page, size: PAGE_SIZE }) const freezeMutation = useFreezeNamespace() const unfreezeMutation = useUnfreezeNamespace() const archiveMutation = useArchiveNamespace() const restoreMutation = useRestoreNamespace() const deleteMutation = useDeleteNamespace() + const namespaces = namespacePage?.items ?? [] + const totalPages = namespacePage ? Math.max(Math.ceil(namespacePage.total / namespacePage.size), 1) : 1 + + useEffect(() => { + if (!namespacePage) { + return + } + const validPage = resolveValidNamespacePage(page, namespacePage.total, namespacePage.size) + if (validPage !== page) { + setPage(validPage) + } + }, [namespacePage, page]) const handleNamespaceClick = (slug: string) => { navigate({ to: `/space/${encodeURIComponent(slug)}` }) @@ -247,127 +269,134 @@ export function MyNamespacesPage() { ) : undefined} /> - {namespaces && namespaces.length > 0 ? ( -
- {namespaces.map((namespace, idx) => ( - handleNamespaceClick(namespace.slug)} - > -
-
-
-
-

- {namespace.displayName} -

- - - {resolveStatusLabel(namespace.status)} - -
- {namespace.description && ( -

- {namespace.description} -

- )} -
@{namespace.slug}
-
- {resolveHint(namespace.status, namespace.type)} -
-
- {t('myNamespaces.roleLabel')}: {namespace.currentUserRole ?? t('myNamespaces.roleUnknown')} + {namespaces.length > 0 ? ( + <> +
+ {namespaces.map((namespace, idx) => ( + handleNamespaceClick(namespace.slug)} + > +
+
+
+
+

+ {namespace.displayName} +

+ + + {resolveStatusLabel(namespace.status)} + +
+ {namespace.description && ( +

+ {namespace.description} +

+ )} +
@{namespace.slug}
+
+ {resolveHint(namespace.status, namespace.type)} +
+
+ {t('myNamespaces.roleLabel')}: {namespace.currentUserRole ?? t('myNamespaces.roleUnknown')} +
+
+ {namespace.type === 'TEAM' && Boolean(namespace.currentUserRole) && ( + + )} + {Boolean(namespace.currentUserRole) && ( + + )} + {namespace.canFreeze && ( + + )} + {namespace.canUnfreeze && ( + + )} + {namespace.canArchive && ( + + )} + {namespace.canRestore && ( + + )} + {namespace.canDelete && ( + + )} +
-
- {namespace.type === 'TEAM' && ( - - )} - - {namespace.canFreeze && ( - - )} - {namespace.canUnfreeze && ( - - )} - {namespace.canArchive && ( - - )} - {namespace.canRestore && ( - - )} - {namespace.canDelete && ( - - )} -
-
- - ))} -
+ + ))} +
+ {namespacePage && namespacePage.total > namespacePage.size ? ( + + ) : null} + ) : ( void }) => void) | undefined }> = [] const useMySkillsMock = vi.fn() +const useMyNamespacesPageMock = vi.fn() vi.mock('@tanstack/react-router', () => ({ useNavigate: () => navigateMock, @@ -72,7 +73,7 @@ vi.mock('@/shared/hooks/use-user-queries', () => ({ })) vi.mock('@/shared/hooks/use-namespace-queries', () => ({ - useMyNamespaces: () => ({ data: [] }), + useMyNamespacesPage: (...args: unknown[]) => useMyNamespacesPageMock(...args), })) vi.mock('@/shared/hooks/use-debounce', () => ({ @@ -114,6 +115,13 @@ describe('MySkillsPage', () => { beforeEach(() => { navigateMock.mockReset() buttonRecords.length = 0 + useMyNamespacesPageMock.mockReset() + useMyNamespacesPageMock.mockReturnValue({ + data: { items: [], total: 0, page: 0, size: 20 }, + isLoading: false, + error: null, + refetch: vi.fn(), + }) useMySkillsMock.mockReturnValue({ data: { items: [ @@ -137,6 +145,12 @@ describe('MySkillsPage', () => { }) }) + it('loads only the first namespace picker page while the picker is closed', () => { + renderToStaticMarkup(createElement(MySkillsPage)) + + expect(useMyNamespacesPageMock).toHaveBeenCalledWith({ page: 0, size: 20 }, false) + }) + it('navigates to publish page with namespace and visibility when update is clicked', () => { renderToStaticMarkup(createElement(MySkillsPage)) diff --git a/web/src/pages/dashboard/my-skills.tsx b/web/src/pages/dashboard/my-skills.tsx index 4dbe4765..0c70eac7 100644 --- a/web/src/pages/dashboard/my-skills.tsx +++ b/web/src/pages/dashboard/my-skills.tsx @@ -5,13 +5,12 @@ import { useAuth } from '@/features/auth/use-auth' import { Button } from '@/shared/ui/button' import { Card } from '@/shared/ui/card' import { Input } from '@/shared/ui/input' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { EmptyState } from '@/shared/components/empty-state' import { ConfirmDialog } from '@/shared/components/confirm-dialog' import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' +import { NamespacePicker } from '@/shared/components/namespace-picker' import { Pagination } from '@/shared/components/pagination' import { useArchiveSkill, useUnarchiveSkill, useWithdrawSkillReview } from '@/shared/hooks/use-skill-queries' -import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries' import { useMySkills, useSubmitPromotion } from '@/shared/hooks/use-user-queries' import { useDebounce } from '@/shared/hooks/use-debounce' import { getHeadlineVersion, getPublishedVersion, getOwnerPreviewVersion, hasPendingOwnerPreview } from '@/shared/lib/skill-lifecycle' @@ -22,8 +21,6 @@ import { ApiError } from '@/api/client' import { getMySkillEmptyStateKey, getMySkillFilters, type MySkillFilter } from './my-skill-filters' const PAGE_SIZE = 10 -const ALL_NAMESPACES_VALUE = '__all_namespaces__' - /** * Dashboard page for skills owned by the current user. * @@ -91,8 +88,6 @@ export function MySkillsPage() { q: keyword || undefined, namespace: namespaceFilter || undefined, }) - const { data: namespaceOptions } = useMyNamespaces() - const skills = skillPage?.items ?? [] const totalPages = skillPage ? Math.max(Math.ceil(skillPage.total / skillPage.size), 1) : 1 const availableFilters = getMySkillFilters(hasRole('SUPER_ADMIN')) @@ -302,24 +297,15 @@ export function MySkillsPage() { aria-label={t('mySkills.searchPlaceholder')} className="sm:max-w-md" /> - +
+ { + updateSearch({ namespace: value || undefined, page: 0 }) + }} + emptyValueLabel={t('mySkills.namespaceFilterAll')} + /> +
{hasActiveSearch ? ( +
+ ) : !selectedNamespace ? ( +

{t('publish.namespaceUnavailable')}

+ ) : null + ) : null}
@@ -230,7 +236,7 @@ export function PublishPage() { className="w-full text-primary-foreground disabled:text-primary-foreground" size="lg" onClick={handlePublish} - disabled={!selectedFile || !namespaceSlug || publishMutation.isPending} + disabled={!selectedFile || !namespaceSlug || !selectedNamespace || publishMutation.isPending} > {publishMutation.isPending ? t('publish.publishing') : t('publish.confirm')} diff --git a/web/src/pages/dashboard/reviews.test.ts b/web/src/pages/dashboard/reviews.test.ts index 186c15eb..1da5c5cc 100644 --- a/web/src/pages/dashboard/reviews.test.ts +++ b/web/src/pages/dashboard/reviews.test.ts @@ -74,9 +74,9 @@ vi.mock('@/features/auth/use-auth', () => ({ useAuth: () => ({ hasRole: hasRoleMock, user: userMock }), })) -const useMyNamespacesMock = vi.fn() +const useMyNamespacesPageMock = vi.fn() vi.mock('@/shared/hooks/use-namespace-queries', () => ({ - useMyNamespaces: () => useMyNamespacesMock(), + useMyNamespacesPage: (...args: unknown[]) => useMyNamespacesPageMock(...args), })) vi.mock('@/shared/components/dashboard-page-header', () => ({ @@ -114,11 +114,11 @@ describe('ReviewsPage', () => { paginationProps.length = 0 hasRoleMock.mockReset() useReviewListMock.mockReset() - useMyNamespacesMock.mockReset() + useMyNamespacesPageMock.mockReset() hasRoleMock.mockImplementation((role: string) => role === 'SKILL_ADMIN') userMock.platformRoles = ['SKILL_ADMIN'] - useMyNamespacesMock.mockReturnValue({ - data: [], + useMyNamespacesPageMock.mockReturnValue({ + data: { items: [], total: 0, page: 0, size: 1 }, isLoading: false, }) useSearchMock.mockReturnValue({}) @@ -194,4 +194,36 @@ describe('ReviewsPage', () => { expect(useReviewListMock).toHaveBeenCalled() expect(useReviewListMock.mock.calls.every((call) => call[5] === false)).toBe(true) }) + + it('skips namespace loading for super admins with global review access', () => { + hasRoleMock.mockImplementation((role: string) => role === 'SKILL_ADMIN' || role === 'USER_ADMIN' || role === 'SUPER_ADMIN') + userMock.platformRoles = ['SUPER_ADMIN'] + + renderToStaticMarkup(createElement(ReviewsPage)) + + expect(useMyNamespacesPageMock).toHaveBeenCalledWith({ + page: 0, + size: 1, + status: 'ACTIVE', + type: 'TEAM', + roles: ['OWNER', 'ADMIN'], + }, false) + }) + + it('renders a recoverable error when namespace review access cannot be loaded', () => { + userMock.platformRoles = ['USER'] + hasRoleMock.mockReturnValue(false) + useMyNamespacesPageMock.mockReturnValue({ + data: undefined, + isLoading: false, + error: new Error('network down'), + refetch: vi.fn(), + }) + + const html = renderToStaticMarkup(createElement(ReviewsPage)) + + expect(html).toContain('reviews.namespaceLoadError') + expect(html).toContain('reviews.retryNamespaceLoad') + expect(html).not.toContain('Loading...') + }) }) diff --git a/web/src/pages/dashboard/reviews.tsx b/web/src/pages/dashboard/reviews.tsx index 94c0ded8..98921cef 100644 --- a/web/src/pages/dashboard/reviews.tsx +++ b/web/src/pages/dashboard/reviews.tsx @@ -2,15 +2,15 @@ import { useEffect, useState } from 'react' import { useNavigate, useSearch } from '@tanstack/react-router' import { FileCheck2 } from 'lucide-react' import { useTranslation } from 'react-i18next' -import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card' +import { Button } from '@/shared/ui/button' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs' import { buildNamespaceReviewsPath, canAccessGlobalReviewCenter, - getPreferredNamespaceReviewEntry, } from '@/features/review/review-paths' +import { useNamespaceReviewEntry } from '@/features/review/use-namespace-review-entry' import { Table, TableBody, @@ -40,7 +40,6 @@ export function ReviewsPage() { const navigate = useNavigate() const search = useSearch({ from: '/dashboard/reviews' }) const { hasRole, user } = useAuth() - const { data: myNamespaces, isLoading: isLoadingNamespaces } = useMyNamespaces() const [pages, setPages] = useState>({ PENDING: 0, APPROVED: 0, @@ -52,7 +51,12 @@ export function ReviewsPage() { const isSkillAdmin = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN') const isUserAdmin = hasRole('USER_ADMIN') || hasRole('SUPER_ADMIN') const hasGlobalReviewAccess = canAccessGlobalReviewCenter(user?.platformRoles) - const namespaceReviewEntry = getPreferredNamespaceReviewEntry(myNamespaces) + const { + namespaceReviewEntry, + isLoadingNamespaces, + hasNamespaceQueryError, + retryNamespaceQueries, + } = useNamespaceReviewEntry(hasGlobalReviewAccess) const showTypeTabs = isSkillAdmin && isUserAdmin // Determine default top-level tab @@ -62,7 +66,7 @@ export function ReviewsPage() { const skillReviewEnabled = hasGlobalReviewAccess && isSkillAdmin && activeType === 'skill' useEffect(() => { - if (hasGlobalReviewAccess || isLoadingNamespaces) { + if (hasGlobalReviewAccess || isLoadingNamespaces || hasNamespaceQueryError) { return } @@ -72,7 +76,7 @@ export function ReviewsPage() { } void navigate({ to: '/dashboard', replace: true }) - }, [hasGlobalReviewAccess, isLoadingNamespaces, namespaceReviewEntry, navigate]) + }, [hasGlobalReviewAccess, hasNamespaceQueryError, isLoadingNamespaces, namespaceReviewEntry, navigate]) const pendingQuery = useReviewList('PENDING', undefined, pages.PENDING, PAGE_SIZE, sortDirection, skillReviewEnabled && activeStatus === 'PENDING') const approvedQuery = useReviewList('APPROVED', undefined, pages.APPROVED, PAGE_SIZE, sortDirection, skillReviewEnabled && activeStatus === 'APPROVED') @@ -250,7 +254,16 @@ export function ReviewsPage() {
- Loading... + {hasNamespaceQueryError ? ( +
+

{t('reviews.namespaceLoadError')}

+ +
+ ) : ( + t('reviews.loadingNamespaceAccess') + )}
) diff --git a/web/src/pages/namespace.test.tsx b/web/src/pages/namespace.test.tsx index c950fb55..0370ec74 100644 --- a/web/src/pages/namespace.test.tsx +++ b/web/src/pages/namespace.test.tsx @@ -47,30 +47,9 @@ vi.mock('@/shared/hooks/use-namespace-queries', () => ({ useNamespaceDetail: () => useNamespaceDetailMock(), })) +const useSearchSkillsMock = vi.fn() vi.mock('@/shared/hooks/use-skill-queries', () => ({ - useSearchSkills: () => ({ - data: { - items: [ - { - id: 1, - displayName: 'Demo Skill', - summary: 'summary', - namespace: 'global', - slug: 'demo', - downloadCount: 1, - starCount: 1, - ratingCount: 0, - updatedAt: '2026-03-20T00:00:00Z', - canSubmitPromotion: false, - publishedVersion: { id: 10, version: '1.0.0', status: 'PUBLISHED' }, - }, - ], - total: 1, - page: 0, - size: 20, - }, - isLoading: false, - }), + useSearchSkills: () => useSearchSkillsMock(), })) import { renderToStaticMarkup } from 'react-dom/server' @@ -83,6 +62,30 @@ describe('NamespacePage', () => { data: { id: 1, slug: 'global', displayName: 'Global', type: 'GLOBAL', status: 'ACTIVE' }, isLoading: false, }) + useSearchSkillsMock.mockReturnValue({ + data: { + items: [ + { + id: 1, + displayName: 'Demo Skill', + summary: 'summary', + namespace: 'global', + slug: 'demo', + downloadCount: 1, + starCount: 1, + ratingCount: 0, + updatedAt: '2026-03-20T00:00:00Z', + canSubmitPromotion: false, + publishedVersion: { id: 10, version: '1.0.0', status: 'PUBLISHED' }, + }, + ], + total: 1, + page: 0, + size: 20, + }, + isLoading: false, + error: null, + }) }) it('exports a named component function', () => { @@ -105,4 +108,17 @@ describe('NamespacePage', () => { expect(buttonRecords).toHaveLength(0) expect(html).not.toContain('type="checkbox"') }) + + it('renders a skill-list error state instead of the empty namespace state when skill loading fails', () => { + useSearchSkillsMock.mockReturnValue({ + data: undefined, + isLoading: false, + error: new Error('namespace read failed'), + }) + + const html = renderToStaticMarkup() + + expect(html).toContain('namespace.skillListErrorTitle') + expect(html).not.toContain('namespace.emptyTitle') + }) }) diff --git a/web/src/pages/namespace.tsx b/web/src/pages/namespace.tsx index 275f7ba5..ebb5a65e 100644 --- a/web/src/pages/namespace.tsx +++ b/web/src/pages/namespace.tsx @@ -26,7 +26,7 @@ export function NamespacePage() { }, [namespace]) const { data: namespaceData, isLoading: isLoadingNamespace } = useNamespaceDetail(namespace) - const { data: skillsData, isLoading: isLoadingSkills } = useSearchSkills({ + const { data: skillsData, isLoading: isLoadingSkills, error: skillsError } = useSearchSkills({ namespace, page, size: PAGE_SIZE, @@ -59,6 +59,11 @@ export function NamespacePage() {

{t('namespace.skillList')}

{isLoadingSkills ? ( + ) : skillsError ? ( + ) : skillsData && skillsData.items.length > 0 ? ( <>
diff --git a/web/src/shared/components/namespace-picker.test.tsx b/web/src/shared/components/namespace-picker.test.tsx new file mode 100644 index 00000000..2869e76f --- /dev/null +++ b/web/src/shared/components/namespace-picker.test.tsx @@ -0,0 +1,143 @@ +/** @vitest-environment jsdom */ + +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const useMyNamespacesPageMock = vi.hoisted(() => vi.fn()) + +vi.mock('@/shared/hooks/use-namespace-queries', () => ({ + useMyNamespacesPage: useMyNamespacesPageMock, +})) + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})) + +import { NamespacePicker } from './namespace-picker' + +const firstPage = { + items: [ + { + id: 1, + slug: 'active-team', + displayName: 'Active Team', + status: 'ACTIVE', + type: 'TEAM', + immutable: false, + canFreeze: false, + canUnfreeze: false, + canArchive: false, + canRestore: false, + canDelete: false, + }, + ], + total: 21, + page: 0, + size: 20, +} + +describe('NamespacePicker', () => { + beforeEach(() => { + vi.useFakeTimers() + useMyNamespacesPageMock.mockImplementation((params: { page?: number }) => ({ + data: params.page === 1 + ? { ...firstPage, items: [{ ...firstPage.items[0], id: 21, slug: 'next-team', displayName: 'Next Team' }], page: 1 } + : firstPage, + isLoading: false, + error: null, + refetch: vi.fn(), + })) + }) + + afterEach(() => { + cleanup() + vi.useRealTimers() + useMyNamespacesPageMock.mockReset() + }) + + it('debounces an active-only namespace search without loading all pages', () => { + render() + + expect(useMyNamespacesPageMock).toHaveBeenLastCalledWith({ + page: 0, + size: 20, + status: 'ACTIVE', + }, false) + + fireEvent.click(screen.getByRole('button', { name: 'namespacePicker.placeholder' })) + fireEvent.change(screen.getByRole('searchbox', { name: 'namespacePicker.search' }), { + target: { value: 'team ai' }, + }) + + expect(useMyNamespacesPageMock).not.toHaveBeenCalledWith(expect.objectContaining({ q: 'team ai' }), true) + act(() => vi.advanceTimersByTime(300)) + expect(useMyNamespacesPageMock).toHaveBeenLastCalledWith({ + page: 0, + size: 20, + status: 'ACTIVE', + q: 'team ai', + }, true) + }) + + it('keeps the current value in the accessible name when associated with a label', () => { + render( + <> + + + , + ) + + expect(document.getElementById('namespace')).toBe( + screen.getByRole('button', { name: 'Namespace: @active-team' }), + ) + }) + + it('paginates bounded results and emits the selected slug', () => { + const onValueChange = vi.fn() + render() + + expect(screen.getByRole('button', { name: '@selected-outside-page' })).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: '@selected-outside-page' })) + fireEvent.click(screen.getByRole('button', { name: 'namespacePicker.next' })) + + expect(useMyNamespacesPageMock).toHaveBeenLastCalledWith({ page: 1, size: 20 }, true) + fireEvent.click(screen.getByRole('button', { name: 'Next Team (@next-team)' })) + + expect(onValueChange).toHaveBeenCalledWith('next-team') + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('supports clearing an optional namespace filter', () => { + const onValueChange = vi.fn() + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: '@active-team' })) + fireEvent.click(screen.getByRole('button', { name: 'All namespaces' })) + + expect(onValueChange).toHaveBeenCalledWith('') + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('uses the optional empty label for an empty trigger value', () => { + render( + , + ) + + expect(screen.getByRole('button', { name: 'All namespaces' })).toBeTruthy() + }) +}) diff --git a/web/src/shared/components/namespace-picker.tsx b/web/src/shared/components/namespace-picker.tsx new file mode 100644 index 00000000..153a5e43 --- /dev/null +++ b/web/src/shared/components/namespace-picker.tsx @@ -0,0 +1,154 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Button } from '@/shared/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/shared/ui/dialog' +import { Input } from '@/shared/ui/input' +import { useDebounce } from '@/shared/hooks/use-debounce' +import { useMyNamespacesPage } from '@/shared/hooks/use-namespace-queries' + +const PAGE_SIZE = 20 + +interface NamespacePickerProps { + id?: string + accessibleLabel?: string + value: string + onValueChange: (slug: string) => void + status?: 'ACTIVE' | 'FROZEN' | 'ARCHIVED' + disabled?: boolean + emptyValueLabel?: string +} + +/** + * Server-paged namespace selector that keeps request and render size bounded. + */ +export function NamespacePicker({ + id, + accessibleLabel, + value, + onValueChange, + status, + disabled = false, + emptyValueLabel, +}: NamespacePickerProps) { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + const [page, setPage] = useState(0) + const [search, setSearch] = useState('') + const debouncedSearch = useDebounce(search.trim(), 300) + const triggerText = value ? `@${value}` : emptyValueLabel ?? t('namespacePicker.placeholder') + const query = useMyNamespacesPage({ + page, + size: PAGE_SIZE, + ...(status ? { status } : {}), + ...(debouncedSearch ? { q: debouncedSearch } : {}), + }, open) + const totalPages = query.data ? Math.max(Math.ceil(query.data.total / query.data.size), 1) : 1 + + useEffect(() => { + setPage(0) + }, [debouncedSearch, status]) + + const selectNamespace = (slug: string) => { + onValueChange(slug) + setOpen(false) + } + + return ( + + + + + + + {t('namespacePicker.title')} + {t('namespacePicker.description')} + + + setSearch(event.target.value)} + aria-label={t('namespacePicker.search')} + placeholder={t('namespacePicker.searchPlaceholder')} + /> + +
+ {emptyValueLabel ? ( + + ) : null} + {query.isLoading ? ( +

{t('namespacePicker.loading')}

+ ) : query.error ? ( +
+

{t('namespacePicker.error')}

+ +
+ ) : query.data?.items.length ? ( + query.data.items.map((namespace) => ( + + )) + ) : ( +

{t('namespacePicker.empty')}

+ )} +
+ +
+ + + {t('namespacePicker.page', { current: page + 1, total: totalPages })} + + +
+
+
+ ) +} diff --git a/web/src/shared/components/user-menu.test.tsx b/web/src/shared/components/user-menu.test.tsx index 3487bd8b..b8faf624 100644 --- a/web/src/shared/components/user-menu.test.tsx +++ b/web/src/shared/components/user-menu.test.tsx @@ -1,9 +1,14 @@ import type { ReactNode } from 'react' +import type { ManagedNamespace } from '@/api/types' import { renderToStaticMarkup } from 'react-dom/server' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import * as mod from './user-menu' import { UserMenu } from './user-menu' +const useMyNamespacesPageMock = vi.hoisted(() => vi.fn(() => ({ + data: { items: [] as ManagedNamespace[], total: 0, page: 0, size: 1 }, +}))) + vi.mock('react', async () => { const actual = await vi.importActual('react') return { @@ -63,7 +68,7 @@ vi.mock('@/api/client', () => ({ })) vi.mock('@/shared/hooks/use-namespace-queries', () => ({ - useMyNamespaces: () => ({ data: [] }), + useMyNamespacesPage: useMyNamespacesPageMock, })) /** @@ -77,6 +82,10 @@ describe('user-menu module exports', () => { }) describe('UserMenu security settings visibility', () => { + beforeEach(() => { + useMyNamespacesPageMock.mockClear() + }) + it('shows security settings when password changes are allowed, independent of OAuth provider', () => { const html = renderToStaticMarkup( { expect(html).not.toContain('user.menu.security') }) + + it('shows reviews for namespace admins without platform review roles', () => { + useMyNamespacesPageMock.mockReturnValue({ + data: { items: [ + { + id: 10, + slug: 'team-admin', + displayName: 'Team Admin', + type: 'TEAM', + status: 'ACTIVE', + immutable: false, + canFreeze: false, + canUnfreeze: false, + canArchive: false, + canRestore: false, + canDelete: false, + currentUserRole: 'ADMIN', + createdAt: '', + }, + ], total: 1, page: 0, size: 1 }, + }) + + const html = renderToStaticMarkup( + , + ) + + expect(useMyNamespacesPageMock).toHaveBeenCalledWith({ + page: 0, + size: 1, + status: 'ACTIVE', + type: 'TEAM', + roles: ['OWNER', 'ADMIN'], + }, true) + expect(useMyNamespacesPageMock).toHaveBeenCalledWith({ + page: 0, + size: 1, + type: 'TEAM', + roles: ['OWNER', 'ADMIN'], + }, false) + expect(html).toContain('user.menu.reviews') + }) + + it('disables namespace membership loading while rendering the global menu for platform reviewers', () => { + renderToStaticMarkup( + , + ) + + expect(useMyNamespacesPageMock).toHaveBeenCalledWith({ + page: 0, + size: 1, + status: 'ACTIVE', + type: 'TEAM', + roles: ['OWNER', 'ADMIN'], + }, false) + }) }) diff --git a/web/src/shared/components/user-menu.tsx b/web/src/shared/components/user-menu.tsx index 774f1bab..0b90ba30 100644 --- a/web/src/shared/components/user-menu.tsx +++ b/web/src/shared/components/user-menu.tsx @@ -3,8 +3,8 @@ import { useTranslation } from 'react-i18next' import { Link } from '@tanstack/react-router' import { useQueryClient } from '@tanstack/react-query' import { authApi } from '@/api/client' -import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries' -import { buildGlobalReviewsPath, canAccessReviewCenter } from '@/features/review/review-paths' +import { buildGlobalReviewsPath, canAccessGlobalReviewCenter } from '@/features/review/review-paths' +import { useNamespaceReviewEntry } from '@/features/review/use-namespace-review-entry' import { clearSessionScopedQueries } from '@/features/notification/notification-session' import { canViewGovernanceCenter } from '@/shared/lib/governance-access' import { withBasePath } from '@/shared/lib/base-path' @@ -26,7 +26,6 @@ interface UserMenuProps { export function UserMenu({ user, triggerClassName }: UserMenuProps) { const { t } = useTranslation() const queryClient = useQueryClient() - const { data: myNamespaces } = useMyNamespaces() const rootRef = useRef(null) const closeTimerRef = useRef(null) const [isHovered, setIsHovered] = useState(false) @@ -38,7 +37,9 @@ export function UserMenu({ user, triggerClassName }: UserMenuProps) { const isUserAdmin = hasRole('USER_ADMIN') || hasRole('SUPER_ADMIN') const isAuditor = hasRole('AUDITOR') || hasRole('SUPER_ADMIN') const isSuperAdmin = hasRole('SUPER_ADMIN') - const reviewCenterVisible = canAccessReviewCenter(user.platformRoles, myNamespaces) + const hasGlobalReviewAccess = canAccessGlobalReviewCenter(user.platformRoles) + const { namespaceReviewEntry } = useNamespaceReviewEntry(hasGlobalReviewAccess) + const reviewCenterVisible = hasGlobalReviewAccess || namespaceReviewEntry !== null const canChangePassword = user.canChangePassword === true const open = isHovered || isClickOpen diff --git a/web/src/shared/hooks/use-namespace-queries.test.ts b/web/src/shared/hooks/use-namespace-queries.test.ts index 68b2823b..cf90900f 100644 --- a/web/src/shared/hooks/use-namespace-queries.test.ts +++ b/web/src/shared/hooks/use-namespace-queries.test.ts @@ -1,4 +1,20 @@ -import { describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const useQueryMock = vi.hoisted(() => vi.fn()) +const listMinePageMock = vi.hoisted(() => vi.fn()) + +vi.mock('@tanstack/react-query', () => ({ + useQuery: useQueryMock, + useMutation: vi.fn(), + useQueryClient: vi.fn(), +})) + +vi.mock('@/api/client', () => ({ + namespaceApi: { + listMine: vi.fn(), + listMinePage: listMinePageMock, + }, +})) /** * use-namespace-queries.ts exports React hooks that wrap @tanstack/react-query @@ -10,9 +26,14 @@ import { describe, expect, it } from 'vitest' * Here we verify that all expected hooks are exported. */ describe('use-namespace-queries exports', () => { + beforeEach(() => { + useQueryMock.mockClear() + listMinePageMock.mockReset() + }) + it('exports all expected hook functions', async () => { const mod = await import('./use-namespace-queries') - expect(typeof mod.useMyNamespaces).toBe('function') + expect(typeof mod.useMyNamespacesPage).toBe('function') expect(typeof mod.useCreateNamespace).toBe('function') expect(typeof mod.useNamespaceDetail).toBe('function') expect(typeof mod.useNamespaceMembers).toBe('function') @@ -25,4 +46,49 @@ describe('use-namespace-queries exports', () => { expect(typeof mod.useArchiveNamespace).toBe('function') expect(typeof mod.useRestoreNamespace).toBe('function') }) + + it('passes bounded filters to a single paged my namespaces query', async () => { + const mod = await import('./use-namespace-queries') + + mod.useMyNamespacesPage({ + page: 3, + size: 15, + status: 'ACTIVE', + type: 'TEAM', + q: 'team', + slug: 'team-ai', + sort: ['slug,desc'], + roles: ['OWNER', 'ADMIN'], + }) + + expect(useQueryMock).toHaveBeenCalledWith(expect.objectContaining({ + queryKey: ['namespaces', 'my', { + page: 3, + size: 15, + status: 'ACTIVE', + type: 'TEAM', + q: 'team', + slug: 'team-ai', + sort: ['slug,desc'], + roles: ['OWNER', 'ADMIN'], + }], + })) + const queryOptions = useQueryMock.mock.calls[useQueryMock.mock.calls.length - 1]?.[0] + listMinePageMock.mockResolvedValue({ items: [], total: 101, page: 3, size: 15 }) + + await queryOptions.queryFn() + + expect(listMinePageMock).toHaveBeenCalledTimes(1) + expect(listMinePageMock).toHaveBeenCalledWith({ + page: 3, + size: 15, + status: 'ACTIVE', + type: 'TEAM', + q: 'team', + slug: 'team-ai', + sort: ['slug,desc'], + roles: ['OWNER', 'ADMIN'], + }) + }) + }) diff --git a/web/src/shared/hooks/use-namespace-queries.ts b/web/src/shared/hooks/use-namespace-queries.ts index be2d7230..2afa6709 100644 --- a/web/src/shared/hooks/use-namespace-queries.ts +++ b/web/src/shared/hooks/use-namespace-queries.ts @@ -1,11 +1,28 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import type { Namespace, NamespaceMember, ManagedNamespace, CreateNamespaceRequest, NamespaceCandidateUser, NamespaceRole, BatchMemberResponse, PagedResponse } from '@/api/types' -import { namespaceApi } from '@/api/client' +import { namespaceApi, type MyNamespacePageParams } from '@/api/client' import { replaceNamespaceMemberRole } from '@/shared/lib/namespace-member-cache' import { shouldEnableNamespaceMemberCandidates } from './skill-query-helpers' -async function getMyNamespaces(): Promise { - return namespaceApi.listMine() +const MY_NAMESPACES_PAGE_SIZE = 20 + +function normalizeMyNamespacePageParams(params: MyNamespacePageParams = {}): MyNamespacePageParams { + const q = params.q?.trim() + const slug = params.slug?.trim() + return { + page: params.page ?? 0, + size: params.size ?? MY_NAMESPACES_PAGE_SIZE, + ...(params.status ? { status: params.status } : {}), + ...(params.type ? { type: params.type } : {}), + ...(q ? { q } : {}), + ...(slug ? { slug } : {}), + ...(params.sort?.length ? { sort: [...params.sort] } : {}), + ...(params.roles?.length ? { roles: [...params.roles] } : {}), + } +} + +async function getMyNamespacesPage(params: MyNamespacePageParams): Promise> { + return namespaceApi.listMinePage(params) } async function createNamespace(request: CreateNamespaceRequest): Promise { @@ -51,10 +68,12 @@ function invalidateNamespaceQueries(queryClient: ReturnType getMyNamespacesPage(normalizedParams), + enabled, }) }