mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-27 11:14:59 +00:00
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:
commit
b3b60b4894
64 changed files with 3629 additions and 442 deletions
|
|
@ -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 状态读取
|
||||
|
||||
|
|
|
|||
|
|
@ -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` 自动跟随最新已发布版本,不可手动移动。
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}),
|
||||
|
|
|
|||
140
web/e2e/my-namespaces-pagination.spec.ts
Normal file
140
web/e2e/my-namespaces-pagination.spec.ts
Normal 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()
|
||||
})
|
||||
}
|
||||
145
web/e2e/my-namespaces-super-admin-actions.spec.ts
Normal file
145
web/e2e/my-namespaces-super-admin-actions.spec.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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',
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)}`)
|
||||
},
|
||||
|
|
|
|||
124
web/src/api/generated/schema.d.ts
vendored
124
web/src/api/generated/schema.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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'
|
||||
103
web/src/features/review/use-namespace-review-entry.test.ts
Normal file
103
web/src/features/review/use-namespace-review-entry.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
43
web/src/features/review/use-namespace-review-entry.ts
Normal file
43
web/src/features/review/use-namespace-review-entry.ts
Normal 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(),
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "公开",
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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...')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
143
web/src/shared/components/namespace-picker.test.tsx
Normal file
143
web/src/shared/components/namespace-picker.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
154
web/src/shared/components/namespace-picker.tsx
Normal file
154
web/src/shared/components/namespace-picker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue