Merge remote-tracking branch 'refs/remotes/origin/pr/581' into codex/validate/pr581-20260806

# Conflicts:
#	server/skillhub-app/src/test/java/com/iflytek/skillhub/security/ApiAccessDeniedHandlerTest.java
#	web/src/pages/dashboard/publish.tsx
This commit is contained in:
XiaoSeS 2026-08-06 22:24:38 +08:00
commit b3b60b4894
64 changed files with 3629 additions and 442 deletions

View file

@ -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 状态读取

View file

@ -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` 自动跟随最新已发布版本,不可手动移动。

View file

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

View file

@ -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<PageResponse<NamespaceResponse>> listNamespaces(
Pageable pageable,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
return ok("response.success.read", namespacePortalQueryAppService.listNamespaces(pageable, userNsRoles));
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
@RequestAttribute(value = "platformRoles", required = false) Set<String> platformRoles) {
return ok("response.success.read",
namespacePortalQueryAppService.listNamespaces(pageable, userNsRoles, normalizePlatformRoles(platformRoles)));
}
@GetMapping("/me/namespaces")
public ApiResponse<List<MyNamespaceResponse>> listMyNamespaces(
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
return ok("response.success.read", namespacePortalQueryAppService.listMyNamespaces(userNsRoles));
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
@RequestAttribute(value = "platformRoles", required = false) Set<String> platformRoles) {
return ok("response.success.read",
namespacePortalQueryAppService.listMyNamespaces(userNsRoles, normalizePlatformRoles(platformRoles)));
}
@GetMapping("/me/namespaces/page")
public ApiResponse<PageResponse<MyNamespaceResponse>> 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<String> 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<NamespaceRole> roles,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
@RequestAttribute(value = "platformRoles", required = false) Set<String> 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<NamespaceResponse> getNamespace(@PathVariable String slug,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
@RequestAttribute(value = "platformRoles", required = false) Set<String> 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<String> normalizePlatformRoles(Set<String> platformRoles) {
return platformRoles != null
? platformRoles
: Set.of();
}
private Pageable myNamespacesPageable(int page, int size, List<String> sort) {
return PageRequest.of(
Math.max(page, 0),
Math.max(size, 1),
myNamespacesSort(sort)
);
}
private Sort myNamespacesSort(List<String> sort) {
if (sort == null || sort.isEmpty()) {
return Sort.unsorted();
}
List<Sort.Order> 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<List<NamespaceCandidateUserResponse>> searchMemberCandidates(
@PathVariable String slug,

View file

@ -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<Long, NamespaceRole> userNsRoles) {
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
@RequestAttribute(value = "platformRoles", required = false) Set<String> 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<Long, NamespaceRole> userNsRoles) {
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
@RequestAttribute(value = "platformRoles", required = false) Set<String> platformRoles) {
Page<SkillVersion> versions = skillQueryService.listVersions(
namespace,
slug,
userId,
userNsRoles != null ? userNsRoles : Map.of(),
PageRequest.of(page, size));
PageRequest.of(page, size),
normalizePlatformRoles(platformRoles));
PageResponse<SkillVersionResponse> 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<Long, NamespaceRole> userNsRoles) {
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
@RequestAttribute(value = "platformRoles", required = false) Set<String> 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<Long, NamespaceRole> userNsRoles) {
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
@RequestAttribute(value = "platformRoles", required = false) Set<String> 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<Long, NamespaceRole> userNsRoles) {
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
@RequestAttribute(value = "platformRoles", required = false) Set<String> platformRoles) {
List<SkillFile> files = skillQueryService.listFiles(
namespace,
slug,
version,
userId,
userNsRoles != null ? userNsRoles : Map.of()
userNsRoles != null ? userNsRoles : Map.of(),
normalizePlatformRoles(platformRoles)
);
List<SkillFileResponse> 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<Long, NamespaceRole> userNsRoles) {
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
@RequestAttribute(value = "platformRoles", required = false) Set<String> platformRoles) {
List<SkillFile> files = skillQueryService.listFilesByTag(
namespace,
slug,
tagName,
userId,
userNsRoles != null ? userNsRoles : Map.of()
userNsRoles != null ? userNsRoles : Map.of(),
normalizePlatformRoles(platformRoles)
);
List<SkillFileResponse> 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<Long, NamespaceRole> userNsRoles) {
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
@RequestAttribute(value = "platformRoles", required = false) Set<String> 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<Long, NamespaceRole> userNsRoles) {
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
@RequestAttribute(value = "platformRoles", required = false) Set<String> 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<Long, NamespaceRole> userNsRoles) {
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
@RequestAttribute(value = "platformRoles", required = false) Set<String> 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<Long, NamespaceRole> userNsRoles) {
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
@RequestAttribute(value = "platformRoles", required = false) Set<String> 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<Long, NamespaceRole> userNsRoles) {
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
@RequestAttribute(value = "platformRoles", required = false) Set<String> 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<Long, NamespaceRole> userNsRoles) {
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
@RequestAttribute(value = "platformRoles", required = false) Set<String> 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<String> normalizePlatformRoles(Set<String> platformRoles) {
return platformRoles != null ? platformRoles : Set.of();
}
private boolean shouldRedirectToPresignedUrl(HttpServletRequest request, String presignedUrl) {
if (presignedUrl == null || presignedUrl.isBlank()) {
return false;

View file

@ -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<Long, NamespaceRole> 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<UserRoleBinding> roleBindings = userRoleBindingRepository.findByUserId(principal.userId());
Set<String> freshRoles = PlatformRoleDefaults.withDefaultUserRole(
(roleBindings != null ? roleBindings : List.<UserRoleBinding>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<String> platformRoles(PlatformPrincipal principal) {
return principal.platformRoles() != null ? principal.platformRoles() : Set.of();
}
private void clearAuthentication(HttpServletRequest request) {
SecurityContextHolder.clearContext();
HttpSession session = request.getSession(false);

View file

@ -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<NamespaceResponse> listNamespaces(Pageable pageable, Map<Long, NamespaceRole> userNamespaceRoles) {
return listNamespaces(pageable, userNamespaceRoles, Set.of());
}
@Transactional(readOnly = true)
public PageResponse<NamespaceResponse> listNamespaces(Pageable pageable,
Map<Long, NamespaceRole> userNamespaceRoles,
Set<String> platformRoles) {
if (isSuperAdmin(platformRoles)) {
Page<Namespace> 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<Long, NamespaceRole> namespaceRoles = userNamespaceRoles != null ? userNamespaceRoles : Map.of();
if (namespaceRoles.isEmpty()) {
Page<NamespaceResponse> empty = new PageImpl<>(
@ -84,29 +110,125 @@ public class NamespacePortalQueryAppService {
@Transactional(readOnly = true)
public List<MyNamespaceResponse> listMyNamespaces(Map<Long, NamespaceRole> userNamespaceRoles) {
return listMyNamespaces(userNamespaceRoles, Set.of());
}
@Transactional(readOnly = true)
public List<MyNamespaceResponse> listMyNamespaces(Map<Long, NamespaceRole> userNamespaceRoles,
Set<String> platformRoles) {
Map<Long, NamespaceRole> 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<Namespace> 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<Long, NamespaceRole> userNamespaceRoles) {
public PageResponse<MyNamespaceResponse> listMyNamespaces(Pageable pageable,
Map<Long, NamespaceRole> userNamespaceRoles,
Set<String> platformRoles) {
Map<Long, NamespaceRole> 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<MyNamespaceResponse> empty = new PageImpl<>(List.of(), boundedPageable, 0);
return PageResponse.from(empty);
}
if (isSuperAdmin(platformRoles)) {
Page<Namespace> visibleNamespaces = namespaceRepository.findAll(boundedPageable);
return PageResponse.from(visibleNamespaces.map(namespace -> myNamespaceResponse(namespace, namespaceRoles)));
}
List<Namespace> 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<MyNamespaceResponse> 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<MyNamespaceResponse> listMyNamespaces(Pageable pageable,
Map<Long, NamespaceRole> userNamespaceRoles,
Set<String> platformRoles,
NamespaceStatus status,
NamespaceType type,
String query,
String slug,
Set<NamespaceRole> roles) {
Map<Long, NamespaceRole> namespaceRoles = userNamespaceRoles != null ? userNamespaceRoles : Map.of();
Set<NamespaceRole> requestedRoles = roles != null ? roles : Set.of();
Pageable boundedPageable = normalizeMyNamespacesPageable(pageable);
String normalizedQuery = normalizeSearchFilter(query);
String normalizedSlug = normalizeFilter(slug);
if (isSuperAdmin(platformRoles) && requestedRoles.isEmpty()) {
Page<Namespace> visibleNamespaces = namespaceRepository.search(
status,
type,
normalizedQuery,
normalizedSlug,
boundedPageable
);
return PageResponse.from(visibleNamespaces.map(namespace -> myNamespaceResponse(namespace, namespaceRoles)));
}
List<Long> scopedNamespaceIds = namespaceRoles.entrySet().stream()
.filter(entry -> requestedRoles.isEmpty() || requestedRoles.contains(entry.getValue()))
.map(Map.Entry::getKey)
.sorted()
.toList();
if (scopedNamespaceIds.isEmpty()) {
Page<MyNamespaceResponse> empty = new PageImpl<>(List.of(), boundedPageable, 0);
return PageResponse.from(empty);
}
Page<Namespace> 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<Long, NamespaceRole> userNamespaceRoles) {
return getNamespace(slug, userId, userNamespaceRoles, Set.of());
}
@Transactional(readOnly = true)
public NamespaceResponse getNamespace(String slug,
String userId,
Map<Long, NamespaceRole> userNamespaceRoles,
Set<String> platformRoles) {
Map<Long, NamespaceRole> 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<Long, NamespaceRole> namespaceRoles) {
NamespaceRole currentUserRole = namespaceRoles.get(namespace.getId());
return MyNamespaceResponse.from(
namespace,
currentUserRole,
namespaceAccessPolicy,
namespaceService.canDelete(namespace, currentUserRole));
}
private List<Namespace> listAllNamespacesByPage() {
List<Namespace> namespaces = new ArrayList<>();
int pageNumber = 0;
Page<Namespace> 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<String> platformRoles) {
return platformRoles != null && platformRoles.contains(SUPER_ADMIN_ROLE);
}
}

View file

@ -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<Long, NamespaceRole> userNsRoles) {
Long namespaceId = resolveNamespaceId(namespaceSlug, userId, userNsRoles);
Set<String> 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<Long, NamespaceRole> userNsRoles) {
Long namespaceId = resolveNamespaceId(namespaceSlug, userId, userNsRoles);
SearchVisibilityScope scope = buildVisibilityScope(userId, userNsRoles);
Set<String> 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<Long, NamespaceRole> userNsRoles) {
private Long resolveNamespaceId(String namespaceSlug,
String userId,
Map<Long, NamespaceRole> userNsRoles,
Set<String> 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<Long, NamespaceRole> userNsRoles) {
private SearchVisibilityScope buildVisibilityScope(String userId,
Map<Long, NamespaceRole> userNsRoles,
Set<String> platformRoles,
Long selectedNamespaceId) {
if (userId == null) {
return SearchVisibilityScope.anonymous();
}
Map<Long, NamespaceRole> normalizedRoles = userNsRoles != null ? userNsRoles : Map.of();
Set<Long> memberNamespaceIds = normalizedRoles.keySet();
Set<Long> memberNamespaceIds = new HashSet<>(normalizedRoles.keySet());
if (hasSuperAdminRole(platformRoles) && selectedNamespaceId != null) {
memberNamespaceIds.add(selectedNamespaceId);
}
Set<Long> 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<String> platformRoles = rbacService.getUserRoleCodes(userId);
return new SearchVisibilityScope(
userId,
memberNamespaceIds,
@ -134,6 +148,10 @@ public class SkillSearchAppService {
);
}
private boolean hasSuperAdminRole(Set<String> platformRoles) {
return platformRoles != null && platformRoles.contains(SUPER_ADMIN_ROLE);
}
private boolean hasPlatformWideReadAccess(Set<String> platformRoles) {
// Super admins should use a dedicated admin interface, not the public portal
return false;

View file

@ -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<String> 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<String> 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<SimpleGrantedAuthority> authorities(Set<String> platformRoles) {
Set<String> roles = platformRoles == null || platformRoles.isEmpty() ? Set.of("USER") : platformRoles;
return roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.toList();
}
private List<String> 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<String> 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<String> 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;
}
}

View file

@ -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.<Long, NamespaceRole>of())))
eq(Map.<Long, NamespaceRole>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.<Long, NamespaceRole>of())))
eq(Map.<Long, NamespaceRole>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.<Long, NamespaceRole>of())))
eq(Map.<Long, NamespaceRole>of()),
anySet()))
.thenReturn(new SkillQueryService.ResolvedVersionDTO(
1L,
"team",
@ -150,7 +155,8 @@ class SkillControllerTest {
eq("team"),
eq("demo"),
eq((String) null),
eq(Map.<Long, NamespaceRole>of())))
eq(Map.<Long, NamespaceRole>of()),
anySet()))
.thenReturn(new SkillQueryService.SkillDetailDTO(
1L,
"demo",
@ -198,7 +204,8 @@ class SkillControllerTest {
eq("team"),
eq("demo"),
eq((String) null),
eq(Map.<Long, NamespaceRole>of())))
eq(Map.<Long, NamespaceRole>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<String> platformRoles = Set.of("SUPER_ADMIN");
when(skillQueryService.getSkillDetail(
eq("team"),
eq("demo"),
eq("super-1"),
eq(Map.<Long, NamespaceRole>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.<Long, NamespaceRole>of())))
eq(Map.<Long, NamespaceRole>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.<Long, NamespaceRole>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.<Long, NamespaceRole>of())))
eq(Map.<Long, NamespaceRole>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.<Long, NamespaceRole>of())))
eq(Map.<Long, NamespaceRole>of()),
anySet()))
.thenThrow(new DomainBadRequestException("error.skill.version.compare.same"));
mockMvc.perform(get("/api/v1/skills/team/demo/versions/compare")

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<SearchQuery> 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);

View file

@ -13,7 +13,23 @@ public interface NamespaceRepository {
Optional<Namespace> findById(Long id);
List<Namespace> findByIdIn(List<Long> ids);
Optional<Namespace> findBySlug(String slug);
Page<Namespace> findAll(Pageable pageable);
Page<Namespace> findByStatus(NamespaceStatus status, Pageable pageable);
Page<Namespace> search(
NamespaceStatus status,
NamespaceType type,
String query,
String slug,
Pageable pageable
);
Page<Namespace> searchByIdIn(
List<Long> ids,
NamespaceStatus status,
NamespaceType type,
String query,
String slug,
Pageable pageable
);
Namespace save(Namespace namespace);
void delete(Namespace namespace);
}

View file

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

View file

@ -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<Long, NamespaceRole> userNsRoles) {
return downloadLatest(namespaceSlug, skillSlug, currentUserId, userNsRoles, Set.of());
}
public DownloadResult downloadLatest(
String namespaceSlug,
String skillSlug,
String currentUserId,
Map<Long, NamespaceRole> userNsRoles,
Set<String> 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<Long, NamespaceRole> userNsRoles) {
return downloadVersion(namespaceSlug, skillSlug, versionStr, currentUserId, userNsRoles, Set.of());
}
public DownloadResult downloadVersion(
String namespaceSlug,
String skillSlug,
String versionStr,
String currentUserId,
Map<Long, NamespaceRole> userNsRoles,
Set<String> 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<Long, NamespaceRole> userNsRoles) {
return downloadByTag(namespaceSlug, skillSlug, tagName, currentUserId, userNsRoles, Set.of());
}
public DownloadResult downloadByTag(
String namespaceSlug,
String skillSlug,
String tagName,
String currentUserId,
Map<Long, NamespaceRole> userNsRoles,
Set<String> 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<Long, NamespaceRole> userNsRoles) {
Map<Long, NamespaceRole> userNsRoles,
Set<String> 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<String> platformRoles) {
return platformRoles != null && platformRoles.contains("SUPER_ADMIN");
}
private Skill resolveVisibleSkill(Long namespaceId, String slug, String currentUserId) {
return skillSlugResolutionService.resolve(
namespaceId,

View file

@ -198,15 +198,34 @@ public class SkillQueryService {
String skillSlug,
String currentUserId,
Map<Long, NamespaceRole> userNsRoles) {
return getSkillDetail(namespaceSlug, skillSlug, currentUserId, userNsRoles, Set.of());
}
public SkillDetailDTO getSkillDetail(
String namespaceSlug,
String skillSlug,
String currentUserId,
Map<Long, NamespaceRole> userNsRoles,
Set<String> 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<Long, NamespaceRole> userNsRoles,
Set<String> 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<Long, NamespaceRole> userNsRoles) {
return getVersionDetail(namespaceSlug, skillSlug, version, currentUserId, userNsRoles, Set.of());
}
public SkillVersionDetailDTO getVersionDetail(
String namespaceSlug,
String skillSlug,
String version,
String currentUserId,
Map<Long, NamespaceRole> userNsRoles,
Set<String> 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<Long, NamespaceRole> 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<Long, NamespaceRole> userNsRoles,
Set<String> 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<Long, NamespaceRole> userNsRoles) {
return listFiles(namespaceSlug, skillSlug, version, currentUserId, userNsRoles, Set.of());
}
public List<SkillFile> listFiles(
String namespaceSlug,
String skillSlug,
String version,
String currentUserId,
Map<Long, NamespaceRole> userNsRoles,
Set<String> 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<Long, NamespaceRole> userNsRoles) {
return listFilesByTag(namespaceSlug, skillSlug, tagName, currentUserId, userNsRoles, Set.of());
}
public List<SkillFile> listFilesByTag(
String namespaceSlug,
String skillSlug,
String tagName,
String currentUserId,
Map<Long, NamespaceRole> userNsRoles,
Set<String> 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<Long, NamespaceRole> 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<Long, NamespaceRole> userNsRoles,
Set<String> 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<Long, NamespaceRole> 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<Long, NamespaceRole> userNsRoles,
Set<String> 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<Long, NamespaceRole> userNsRoles,
Pageable pageable) {
return listVersions(namespaceSlug, skillSlug, currentUserId, userNsRoles, pageable, Set.of());
}
public Page<SkillVersion> listVersions(String namespaceSlug,
String skillSlug,
String currentUserId,
Map<Long, NamespaceRole> userNsRoles,
Pageable pageable,
Set<String> platformRoles) {
Namespace namespace = findNamespace(namespaceSlug);
Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId);
assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles);
assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles, platformRoles);
List<SkillVersion> visibleVersions;
if (canManageRestrictedSkill(skill, currentUserId, userNsRoles)) {
visibleVersions = skillVersionRepository.findBySkillId(skill.getId()).stream()
@ -551,13 +641,25 @@ public class SkillQueryService {
String hash,
String currentUserId,
Map<Long, NamespaceRole> 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<Long, NamespaceRole> userNsRoles,
Set<String> 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<Long, NamespaceRole> 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<Long, NamespaceRole> userNsRoles,
Set<String> 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<String> platformRoles) {
return platformRoles != null && platformRoles.contains("SUPER_ADMIN");
}
private boolean isNamespaceMember(Long namespaceId, String currentUserId, Map<Long, NamespaceRole> userNsRoles) {
return currentUserId != null && userNsRoles.containsKey(namespaceId);
}

View file

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

View file

@ -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<Long, NamespaceRole> userNsRoles = Map.of();
Set<String> 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");

View file

@ -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<SkillVersion> 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";

View file

@ -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<Namespace> findByIdIn(List<Long> ids);
Optional<Namespace> findBySlug(String slug);
Page<Namespace> 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<Namespace> 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<Namespace> searchByIdIn(@Param("ids") List<Long> ids,
@Param("status") NamespaceStatus status,
@Param("type") NamespaceType type,
@Param("query") String query,
@Param("slug") String slug,
Pageable pageable);
}

View file

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

View file

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

View file

@ -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<SkillSearchDocument> documents = skillRepository.findAll().stream()
.filter(skill -> skill.getStatus() == SkillStatus.ACTIVE)
.map(this::toDocument)
.flatMap(Optional::stream)
.toList();
List<SkillSearchDocument> documents = new ArrayList<>();
for (Skill skill : skillRepository.findAll()) {
Optional<SkillSearchDocument> 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<SkillSearchDocument> 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<String> searchParts = new ArrayList<>();
addPart(searchParts, skill.getSlug());
addPart(searchParts, skill.getSummary());
Set<String> 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<SkillVersion> resolveLatestVersion(Skill skill) {
private Optional<SkillVersion> 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<String, Object> extractParsedMetadata(SkillVersion version) {
@ -269,13 +281,17 @@ public class PostgresSearchRebuildService implements SearchRebuildService {
}
private Optional<SkillSearchDocument> toDocument(Skill skill) {
Optional<SkillVersion> latestVersion = resolvePublishedLatestVersion(skill);
if (latestVersion.isEmpty()) {
return Optional.empty();
}
Optional<Namespace> 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(),

View file

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

View file

@ -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<String> 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);

View file

@ -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": {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -49,6 +49,22 @@ import type {
import { ApiError } from '@/shared/lib/api-error'
import i18n from '@/i18n/config'
type OperationQuery<Operation> = Operation extends { parameters: { query?: infer Query } } ? NonNullable<Query> : never
type OperationData<Operation> = Operation extends {
responses: {
200: {
content: {
'*/*': infer Envelope
}
}
}
} ? Envelope extends { data?: infer Data } ? NonNullable<Data> : never : never
type ListMyNamespacesPageOperation = paths['/api/web/me/namespaces/page']['get']
export type MyNamespacePageParams = OperationQuery<ListMyNamespacesPageOperation>
export type MyNamespacePageResponse = OperationData<ListMyNamespacesPageOperation> & PagedResponse<ManagedNamespace>
/**
* Front-end API foundation for generated OpenAPI calls and hand-written convenience wrappers.
*
@ -657,6 +673,27 @@ export const namespaceApi = {
return fetchJson<ManagedNamespace[]>(`${WEB_API_PREFIX}/me/namespaces`)
},
async listMinePage(params: MyNamespacePageParams = {}): Promise<MyNamespacePageResponse> {
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<MyNamespacePageResponse>(`${WEB_API_PREFIX}/me/namespaces/page?${query.toString()}`)
},
async getDetail(slug: string): Promise<Namespace> {
return fetchJson<Namespace>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}`)
},

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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": "公开",

View file

@ -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> = {}): 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')

View file

@ -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<PendingNamespaceAction | null>(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 ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{namespaces.map((namespace, idx) => (
<Card
key={namespace.id}
data-testid={`namespace-card-${namespace.slug}`}
className={`p-6 cursor-pointer group animate-fade-up delay-${Math.min(idx + 1, 6)}`}
onClick={() => handleNamespaceClick(namespace.slug)}
>
<div className="space-y-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="font-semibold font-heading text-lg group-hover:text-primary transition-colors">
{namespace.displayName}
</h3>
<NamespaceBadge
type={namespace.type}
name={namespace.type === 'GLOBAL' ? t('myNamespaces.typeGlobal') : t('myNamespaces.typeTeam')}
/>
<span className={`inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium ${resolveStatusClassName(namespace.status)}`}>
{resolveStatusLabel(namespace.status)}
</span>
</div>
{namespace.description && (
<p className="text-sm text-muted-foreground mb-2 leading-relaxed">
{namespace.description}
</p>
)}
<div className="text-sm text-muted-foreground font-mono">@{namespace.slug}</div>
<div className="mt-3 rounded-lg border border-border/50 bg-secondary/40 px-3 py-2 text-sm text-muted-foreground">
{resolveHint(namespace.status, namespace.type)}
</div>
<div className="mt-2 text-xs uppercase tracking-[0.18em] text-muted-foreground/80">
{t('myNamespaces.roleLabel')}: {namespace.currentUserRole ?? t('myNamespaces.roleUnknown')}
{namespaces.length > 0 ? (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{namespaces.map((namespace, idx) => (
<Card
key={namespace.id}
data-testid={`namespace-card-${namespace.slug}`}
className={`p-6 cursor-pointer group animate-fade-up delay-${Math.min(idx + 1, 6)}`}
onClick={() => handleNamespaceClick(namespace.slug)}
>
<div className="space-y-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="font-semibold font-heading text-lg group-hover:text-primary transition-colors">
{namespace.displayName}
</h3>
<NamespaceBadge
type={namespace.type}
name={namespace.type === 'GLOBAL' ? t('myNamespaces.typeGlobal') : t('myNamespaces.typeTeam')}
/>
<span className={`inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium ${resolveStatusClassName(namespace.status)}`}>
{resolveStatusLabel(namespace.status)}
</span>
</div>
{namespace.description && (
<p className="text-sm text-muted-foreground mb-2 leading-relaxed">
{namespace.description}
</p>
)}
<div className="text-sm text-muted-foreground font-mono">@{namespace.slug}</div>
<div className="mt-3 rounded-lg border border-border/50 bg-secondary/40 px-3 py-2 text-sm text-muted-foreground">
{resolveHint(namespace.status, namespace.type)}
</div>
<div className="mt-2 text-xs uppercase tracking-[0.18em] text-muted-foreground/80">
{t('myNamespaces.roleLabel')}: {namespace.currentUserRole ?? t('myNamespaces.roleUnknown')}
</div>
</div>
</div>
<div className="flex flex-wrap gap-3">
{namespace.type === 'TEAM' && Boolean(namespace.currentUserRole) && (
<Button
variant="outline"
size="sm"
onClick={(e) => handleMembersClick(namespace.slug, e)}
>
{t('myNamespaces.manageMembers')}
</Button>
)}
{Boolean(namespace.currentUserRole) && (
<Button
variant="outline"
size="sm"
onClick={(e) => handleReviewsClick(namespace.slug, e)}
>
{t('myNamespaces.reviewTasks')}
</Button>
)}
{namespace.canFreeze && (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation()
setPendingAction({ action: 'freeze', slug: namespace.slug, name: namespace.displayName })
}}
>
{t('myNamespaces.freeze')}
</Button>
)}
{namespace.canUnfreeze && (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation()
setPendingAction({ action: 'unfreeze', slug: namespace.slug, name: namespace.displayName })
}}
>
{t('myNamespaces.unfreeze')}
</Button>
)}
{namespace.canArchive && (
<Button
variant="destructive"
size="sm"
onClick={(e) => {
e.stopPropagation()
setPendingAction({ action: 'archive', slug: namespace.slug, name: namespace.displayName })
}}
>
{t('myNamespaces.archive')}
</Button>
)}
{namespace.canRestore && (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation()
setPendingAction({ action: 'restore', slug: namespace.slug, name: namespace.displayName })
}}
>
{t('myNamespaces.restore')}
</Button>
)}
{namespace.canDelete && (
<Button
data-testid={`delete-namespace-${namespace.slug}`}
variant="destructive"
size="sm"
onClick={(e) => {
e.stopPropagation()
setPendingAction({ action: 'delete', slug: namespace.slug, name: namespace.displayName })
}}
>
{t('myNamespaces.delete')}
</Button>
)}
</div>
</div>
<div className="flex flex-wrap gap-3">
{namespace.type === 'TEAM' && (
<Button
variant="outline"
size="sm"
onClick={(e) => handleMembersClick(namespace.slug, e)}
>
{t('myNamespaces.manageMembers')}
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={(e) => handleReviewsClick(namespace.slug, e)}
>
{t('myNamespaces.reviewTasks')}
</Button>
{namespace.canFreeze && (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation()
setPendingAction({ action: 'freeze', slug: namespace.slug, name: namespace.displayName })
}}
>
{t('myNamespaces.freeze')}
</Button>
)}
{namespace.canUnfreeze && (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation()
setPendingAction({ action: 'unfreeze', slug: namespace.slug, name: namespace.displayName })
}}
>
{t('myNamespaces.unfreeze')}
</Button>
)}
{namespace.canArchive && (
<Button
variant="destructive"
size="sm"
onClick={(e) => {
e.stopPropagation()
setPendingAction({ action: 'archive', slug: namespace.slug, name: namespace.displayName })
}}
>
{t('myNamespaces.archive')}
</Button>
)}
{namespace.canRestore && (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation()
setPendingAction({ action: 'restore', slug: namespace.slug, name: namespace.displayName })
}}
>
{t('myNamespaces.restore')}
</Button>
)}
{namespace.canDelete && (
<Button
data-testid={`delete-namespace-${namespace.slug}`}
variant="destructive"
size="sm"
onClick={(e) => {
e.stopPropagation()
setPendingAction({ action: 'delete', slug: namespace.slug, name: namespace.displayName })
}}
>
{t('myNamespaces.delete')}
</Button>
)}
</div>
</div>
</Card>
))}
</div>
</Card>
))}
</div>
{namespacePage && namespacePage.total > namespacePage.size ? (
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
) : null}
</>
) : (
<EmptyState
title={t('myNamespaces.emptyTitle')}

View file

@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const navigateMock = vi.fn()
const buttonRecords: Array<{ label: string; onClick?: ((event?: { stopPropagation: () => 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))

View file

@ -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"
/>
<Select
value={namespaceFilter || ALL_NAMESPACES_VALUE}
onValueChange={(value) => {
updateSearch({ namespace: value === ALL_NAMESPACES_VALUE ? undefined : value, page: 0 })
}}
>
<SelectTrigger aria-label={t('mySkills.namespaceFilterLabel')} className="sm:max-w-[14rem]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_NAMESPACES_VALUE}>{t('mySkills.namespaceFilterAll')}</SelectItem>
{(namespaceOptions ?? []).map((ns: { id: number; slug: string }) => (
<SelectItem key={ns.id} value={ns.slug}>
@{ns.slug}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="sm:w-[14rem]">
<NamespacePicker
value={namespaceFilter}
onValueChange={(value) => {
updateSearch({ namespace: value || undefined, page: 0 })
}}
emptyValueLabel={t('mySkills.namespaceFilterAll')}
/>
</div>
{hasActiveSearch ? (
<Button
type="button"

View file

@ -1,5 +1,9 @@
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
const useMyNamespacesPageMock = vi.fn()
vi.mock('@tanstack/react-router', () => ({
useParams: () => ({ slug: 'test-ns' }),
}))
@ -52,7 +56,7 @@ vi.mock('@/shared/ui/select', () => ({
}))
vi.mock('@/shared/hooks/use-namespace-queries', () => ({
useMyNamespaces: () => ({ data: [] }),
useMyNamespacesPage: (...args: unknown[]) => useMyNamespacesPageMock(...args),
useNamespaceDetail: () => ({ data: null, isLoading: false }),
useNamespaceMembers: () => ({ data: [], isLoading: false, error: null }),
useRemoveNamespaceMember: () => ({ mutateAsync: vi.fn() }),
@ -69,4 +73,14 @@ describe('NamespaceMembersPage', () => {
it('exports a named component function', () => {
expect(typeof NamespaceMembersPage).toBe('function')
})
it('loads only the current namespace membership entry', () => {
useMyNamespacesPageMock.mockReturnValue({
data: { items: [], total: 0, page: 0, size: 1 },
})
renderToStaticMarkup(createElement(NamespaceMembersPage))
expect(useMyNamespacesPageMock).toHaveBeenCalledWith({ page: 0, size: 1, slug: 'test-ns' }, true)
})
})

View file

@ -19,7 +19,7 @@ import {
SelectValue,
} from '@/shared/ui/select'
import {
useMyNamespaces,
useMyNamespacesPage,
useNamespaceDetail,
useNamespaceMembers,
useRemoveNamespaceMember,
@ -50,7 +50,7 @@ export function NamespaceMembersPage() {
const { data: namespace, isLoading: isLoadingNamespace } = useNamespaceDetail(slug)
const { data: membersPage, isLoading: isLoadingMembers, error: membersError } = useNamespaceMembers(slug, page, MEMBER_PAGE_SIZE)
const { data: myNamespaces } = useMyNamespaces()
const { data: myNamespacesPage } = useMyNamespacesPage({ page: 0, size: 1, slug }, !!slug)
const updateRoleMutation = useUpdateNamespaceMemberRole()
const removeMemberMutation = useRemoveNamespaceMember()
@ -58,7 +58,7 @@ export function NamespaceMembersPage() {
const totalMembers = membersPage?.total ?? 0
const totalPages = Math.max(1, Math.ceil(totalMembers / MEMBER_PAGE_SIZE))
const currentNamespace = myNamespaces?.find((item) => item.slug === slug)
const currentNamespace = myNamespacesPage?.items.find((item) => item.slug === slug)
const currentUserRole = currentNamespace?.currentUserRole
const isReadOnly = namespace?.type === 'GLOBAL' || namespace?.status !== 'ACTIVE'
// Membership changes are only allowed in active team namespaces and only for

View file

@ -1,9 +1,10 @@
import { createElement } from 'react'
import { createElement, type ReactNode } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const useSearchMock = vi.fn()
const selectRecords: Array<{ value?: string }> = []
const useMyNamespacesPageMock = vi.fn()
vi.mock('@tanstack/react-router', () => ({
useNavigate: () => vi.fn(),
@ -25,7 +26,7 @@ vi.mock('@/features/publish/upload-zone', () => ({
}))
vi.mock('@/shared/ui/button', () => ({
Button: ({ children }: { children: unknown }) => children,
Button: ({ children, ...props }: { children: ReactNode }) => createElement('button', props, children),
}))
vi.mock('@/shared/ui/select', () => ({
@ -53,7 +54,7 @@ vi.mock('@/shared/hooks/use-skill-queries', () => ({
}))
vi.mock('@/shared/hooks/use-namespace-queries', () => ({
useMyNamespaces: () => ({ data: [], isLoading: false }),
useMyNamespacesPage: (...args: unknown[]) => useMyNamespacesPageMock(...args),
}))
vi.mock('@/shared/components/dashboard-page-header', () => ({
@ -75,6 +76,24 @@ import { PublishPage } from './publish'
describe('PublishPage', () => {
beforeEach(() => {
selectRecords.length = 0
useMyNamespacesPageMock.mockReset()
useMyNamespacesPageMock.mockImplementation((params: { slug?: string }) => ({
data: {
items: params.slug ? [{
id: 1,
slug: params.slug,
displayName: 'Team AI',
status: 'ACTIVE',
type: 'TEAM',
}] : [],
total: params.slug ? 1 : 0,
page: 0,
size: params.slug ? 1 : 20,
},
isLoading: false,
error: null,
refetch: vi.fn(),
}))
useSearchMock.mockReturnValue({
namespace: ' team-ai ',
visibility: 'private',
@ -84,8 +103,14 @@ describe('PublishPage', () => {
it('prefills namespace and visibility from route search params', () => {
renderToStaticMarkup(createElement(PublishPage))
expect(selectRecords[0]?.value).toBe('team-ai')
expect(selectRecords[1]?.value).toBe('PRIVATE')
expect(useMyNamespacesPageMock).toHaveBeenCalledWith({
page: 0,
size: 1,
status: 'ACTIVE',
slug: 'team-ai',
}, true)
expect(useMyNamespacesPageMock).toHaveBeenCalledWith({ page: 0, size: 20, status: 'ACTIVE' }, false)
expect(selectRecords[0]?.value).toBe('PRIVATE')
})
it('falls back to public visibility when search params are missing', () => {
@ -93,8 +118,36 @@ describe('PublishPage', () => {
renderToStaticMarkup(createElement(PublishPage))
expect(selectRecords[0]?.value).toBe('__select_namespace__')
expect(selectRecords[1]?.value).toBe('PUBLIC')
expect(useMyNamespacesPageMock).toHaveBeenCalledWith({ page: 0, size: 1, status: 'ACTIVE' }, false)
expect(selectRecords[0]?.value).toBe('PUBLIC')
})
it('marks an archived or unavailable prefilled namespace as invalid', () => {
useMyNamespacesPageMock.mockReturnValue({
data: { items: [], total: 0, page: 0, size: 1 },
isLoading: false,
error: null,
refetch: vi.fn(),
})
const html = renderToStaticMarkup(createElement(PublishPage))
expect(html).toContain('publish.namespaceUnavailable')
})
it('distinguishes a namespace validation request failure from an unavailable namespace', () => {
useMyNamespacesPageMock.mockReturnValue({
data: undefined,
isLoading: false,
error: new Error('network down'),
refetch: vi.fn(),
})
const html = renderToStaticMarkup(createElement(PublishPage))
expect(html).toContain('publish.namespaceValidationError')
expect(html).toContain('publish.retryNamespaceValidation')
expect(html).not.toContain('publish.namespaceUnavailable')
})
it('exports a named component function', () => {

View file

@ -17,20 +17,18 @@ import {
SelectItem,
SelectTrigger,
SelectValue,
normalizeSelectValue,
} from '@/shared/ui/select'
import { Label } from '@/shared/ui/label'
import { Card } from '@/shared/ui/card'
import { usePublishSkill } from '@/shared/hooks/use-skill-queries'
import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries'
import { useMyNamespacesPage } from '@/shared/hooks/use-namespace-queries'
import { ConfirmDialog } from '@/shared/components/confirm-dialog'
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
import { navigateAfterOverlays } from '@/shared/lib/navigate-after-overlays'
import { NamespacePicker } from '@/shared/components/namespace-picker'
import { toast } from '@/shared/lib/toast'
import { ApiError } from '@/api/client'
const EMPTY_NAMESPACE_VALUE = '__select_namespace__'
export function PublishPage() {
const { t } = useTranslation()
const navigate = useNavigate()
@ -42,9 +40,19 @@ export function PublishPage() {
const [warningDialogOpen, setWarningDialogOpen] = useState(false)
const [precheckWarnings, setPrecheckWarnings] = useState<string[]>([])
const { data: namespaces, isLoading: isLoadingNamespaces } = useMyNamespaces()
const {
data: selectedNamespacePage,
isLoading: isLoadingSelectedNamespace,
error: selectedNamespaceError,
refetch: refetchSelectedNamespace,
} = useMyNamespacesPage({
page: 0,
size: 1,
status: 'ACTIVE',
...(namespaceSlug ? { slug: namespaceSlug } : {}),
}, !!namespaceSlug)
const publishMutation = usePublishSkill()
const selectedNamespace = namespaces?.find((ns) => ns.slug === namespaceSlug)
const selectedNamespace = selectedNamespacePage?.items.find((ns) => ns.slug === namespaceSlug)
const namespaceOnlyLabel = selectedNamespace?.type === 'GLOBAL'
? t('publish.visibilityOptions.loggedInUsersOnly')
: t('publish.visibilityOptions.namespaceOnly')
@ -67,7 +75,7 @@ export function PublishPage() {
}
const publishSkill = async (confirmWarnings = false) => {
if (!selectedFile || !namespaceSlug) {
if (!selectedFile || !namespaceSlug || !selectedNamespace) {
toast.error(t('publish.selectRequired'))
return
}
@ -159,28 +167,26 @@ export function PublishPage() {
<Card className="p-8 space-y-8">
<div className="space-y-3">
<Label htmlFor="namespace" className="text-sm font-semibold font-heading">{t('publish.namespace')}</Label>
{isLoadingNamespaces ? (
<div className="h-11 animate-shimmer rounded-lg" />
) : (
<Select
value={normalizeSelectValue(namespaceSlug) ?? EMPTY_NAMESPACE_VALUE}
onValueChange={(value) => {
setNamespaceSlug(value === EMPTY_NAMESPACE_VALUE ? '' : value)
}}
>
<SelectTrigger id="namespace">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={EMPTY_NAMESPACE_VALUE}>{t('publish.selectNamespace')}</SelectItem>
{namespaces?.map((ns) => (
<SelectItem key={ns.id} value={ns.slug}>
{ns.displayName} (@{ns.slug})
</SelectItem>
))}
</SelectContent>
</Select>
)}
<NamespacePicker
id="namespace"
accessibleLabel={t('publish.namespace')}
value={namespaceSlug}
onValueChange={setNamespaceSlug}
status="ACTIVE"
disabled={publishMutation.isPending}
/>
{namespaceSlug && !isLoadingSelectedNamespace ? (
selectedNamespaceError ? (
<div className="flex items-center justify-between gap-3">
<p className="text-sm text-destructive">{t('publish.namespaceValidationError')}</p>
<Button type="button" size="sm" variant="outline" onClick={() => refetchSelectedNamespace()}>
{t('publish.retryNamespaceValidation')}
</Button>
</div>
) : !selectedNamespace ? (
<p className="text-sm text-destructive">{t('publish.namespaceUnavailable')}</p>
) : null
) : null}
</div>
<div className="space-y-3">
@ -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')}
</Button>

View file

@ -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...')
})
})

View file

@ -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<Record<ReviewStatus, number>>({
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() {
<div className="space-y-8 animate-fade-up">
<DashboardPageHeader title={t('reviews.title')} subtitle={t('reviews.subtitle')} />
<Card className="p-8 text-center text-muted-foreground">
Loading...
{hasNamespaceQueryError ? (
<div className="space-y-3">
<p>{t('reviews.namespaceLoadError')}</p>
<Button type="button" variant="outline" onClick={() => retryNamespaceQueries()}>
{t('reviews.retryNamespaceLoad')}
</Button>
</div>
) : (
t('reviews.loadingNamespaceAccess')
)}
</Card>
</div>
)

View file

@ -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(<NamespacePage />)
expect(html).toContain('namespace.skillListErrorTitle')
expect(html).not.toContain('namespace.emptyTitle')
})
})

View file

@ -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() {
<h2 className="text-2xl font-bold font-heading">{t('namespace.skillList')}</h2>
{isLoadingSkills ? (
<SkeletonList count={6} />
) : skillsError ? (
<EmptyState
title={t('namespace.skillListErrorTitle')}
description={t('namespace.skillListErrorDescription')}
/>
) : skillsData && skillsData.items.length > 0 ? (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">

View file

@ -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(<NamespacePicker value="" onValueChange={vi.fn()} status="ACTIVE" />)
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(
<>
<label htmlFor="namespace">Namespace</label>
<NamespacePicker
id="namespace"
accessibleLabel="Namespace"
value="active-team"
onValueChange={vi.fn()}
/>
</>,
)
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(<NamespacePicker value="selected-outside-page" onValueChange={onValueChange} />)
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(
<NamespacePicker
value="active-team"
onValueChange={onValueChange}
emptyValueLabel="All namespaces"
/>,
)
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(
<NamespacePicker
value=""
onValueChange={vi.fn()}
emptyValueLabel="All namespaces"
/>,
)
expect(screen.getByRole('button', { name: 'All namespaces' })).toBeTruthy()
})
})

View file

@ -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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
id={id}
type="button"
variant="outline"
disabled={disabled}
aria-label={accessibleLabel ? `${accessibleLabel}: ${triggerText}` : undefined}
className="w-full justify-start"
>
{triggerText}
</Button>
</DialogTrigger>
<DialogContent aria-label={t('namespacePicker.title')}>
<DialogHeader>
<DialogTitle>{t('namespacePicker.title')}</DialogTitle>
<DialogDescription>{t('namespacePicker.description')}</DialogDescription>
</DialogHeader>
<Input
type="search"
value={search}
onChange={(event) => setSearch(event.target.value)}
aria-label={t('namespacePicker.search')}
placeholder={t('namespacePicker.searchPlaceholder')}
/>
<div className="min-h-44 space-y-2">
{emptyValueLabel ? (
<button
type="button"
onClick={() => selectNamespace('')}
className="flex w-full items-center rounded-lg border border-border px-4 py-3 text-left text-sm font-medium hover:bg-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{emptyValueLabel}
</button>
) : null}
{query.isLoading ? (
<p className="py-8 text-center text-sm text-muted-foreground">{t('namespacePicker.loading')}</p>
) : query.error ? (
<div className="space-y-3 py-8 text-center">
<p className="text-sm text-destructive">{t('namespacePicker.error')}</p>
<Button type="button" size="sm" variant="outline" onClick={() => query.refetch()}>
{t('namespacePicker.retry')}
</Button>
</div>
) : query.data?.items.length ? (
query.data.items.map((namespace) => (
<button
key={namespace.id}
type="button"
aria-label={`${namespace.displayName} (@${namespace.slug})`}
onClick={() => selectNamespace(namespace.slug)}
className="flex w-full items-center justify-between rounded-lg border border-border px-4 py-3 text-left text-sm hover:bg-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span className="font-medium">{namespace.displayName}</span>
<span className="text-muted-foreground">@{namespace.slug}</span>
</button>
))
) : (
<p className="py-8 text-center text-sm text-muted-foreground">{t('namespacePicker.empty')}</p>
)}
</div>
<div className="flex items-center justify-between gap-3">
<Button
type="button"
size="sm"
variant="outline"
disabled={page === 0}
onClick={() => setPage((current) => Math.max(current - 1, 0))}
>
{t('namespacePicker.previous')}
</Button>
<span className="text-xs text-muted-foreground">
{t('namespacePicker.page', { current: page + 1, total: totalPages })}
</span>
<Button
type="button"
size="sm"
variant="outline"
disabled={page + 1 >= totalPages}
onClick={() => setPage((current) => current + 1)}
>
{t('namespacePicker.next')}
</Button>
</div>
</DialogContent>
</Dialog>
)
}

View file

@ -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<typeof import('react')>('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(
<UserMenu
@ -105,4 +114,69 @@ describe('UserMenu security settings visibility', () => {
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(
<UserMenu
user={{
displayName: 'Namespace Admin',
platformRoles: ['USER'],
}}
/>,
)
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(
<UserMenu
user={{
displayName: 'Super Admin',
platformRoles: ['SUPER_ADMIN'],
}}
/>,
)
expect(useMyNamespacesPageMock).toHaveBeenCalledWith({
page: 0,
size: 1,
status: 'ACTIVE',
type: 'TEAM',
roles: ['OWNER', 'ADMIN'],
}, false)
})
})

View file

@ -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<HTMLDivElement | null>(null)
const closeTimerRef = useRef<number | null>(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

View file

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

View file

@ -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<ManagedNamespace[]> {
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<PagedResponse<ManagedNamespace>> {
return namespaceApi.listMinePage(params)
}
async function createNamespace(request: CreateNamespaceRequest): Promise<Namespace> {
@ -51,10 +68,12 @@ function invalidateNamespaceQueries(queryClient: ReturnType<typeof useQueryClien
queryClient.invalidateQueries({ queryKey: ['reviews'] })
}
export function useMyNamespaces() {
export function useMyNamespacesPage(params: MyNamespacePageParams = {}, enabled = true) {
const normalizedParams = normalizeMyNamespacePageParams(params)
return useQuery({
queryKey: ['namespaces', 'my'],
queryFn: getMyNamespaces,
queryKey: ['namespaces', 'my', normalizedParams],
queryFn: () => getMyNamespacesPage(normalizedParams),
enabled,
})
}