From e484b2c72a82c9a319200ec3e4973956e1c34394 Mon Sep 17 00:00:00 2001 From: vsxd Date: Tue, 17 Mar 2026 10:34:01 +0800 Subject: [PATCH 1/3] docs: align role docs with code behavior --- .../governance/user-management.md | 42 ++++++++++----- .../security/authorization.md | 53 +++++++++++++----- .../governance/user-management.md | 42 ++++++++++----- .../security/authorization.md | 54 ++++++++++++++----- 4 files changed, 143 insertions(+), 48 deletions(-) diff --git a/document/docs/02-administration/governance/user-management.md b/document/docs/02-administration/governance/user-management.md index 7cef1f1b..58d9abdc 100644 --- a/document/docs/02-administration/governance/user-management.md +++ b/document/docs/02-administration/governance/user-management.md @@ -8,12 +8,12 @@ description: 平台用户管理 ## 用户状态 -| 状态 | 说明 | -|------|------| -| `ACTIVE` | 正常使用 | -| `PENDING` | 等待审批 | -| `DISABLED` | 已封禁 | -| `MERGED` | 已合并到其他账号 | +| 状态 | 实际逻辑 | +|------|----------| +| `ACTIVE` | 可正常登录和使用系统。OAuth 首次自动准入、local 注册成功后都会进入该状态。 | +| `PENDING` | 账号已建但不可登录。OAuth 在“需要审批”策略下会创建 `PENDING` 用户并跳转到待审批页;local 登录遇到该状态会直接拒绝。 | +| `DISABLED` | 不可登录。OAuth 和 local 登录都会拒绝;`/api/v1/auth/me` 发现当前会话对应用户已被禁用时,会直接清掉 session。 | +| `MERGED` | 账号已并入其他账号,不可继续登录;主要由账号合并流程写入,不是普通用户管理流程的目标状态。 | ## 用户准入 @@ -23,16 +23,34 @@ description: 平台用户管理 ## 角色分配 -USER_ADMIN 可分配平台角色: -- SKILL_ADMIN -- USER_ADMIN -- AUDITOR +`USER_ADMIN` 或 `SUPER_ADMIN` 可调用用户管理接口修改平台角色,但当前实现有几个关键点: -注意:不可分配 SUPER_ADMIN(仅 SUPER_ADMIN 可分配)。 +- 接口一次只能设置一个目标平台角色。 +- 设置时会删除该用户已有的显式平台角色,再写入新的那个角色。 +- 如果设置为 `USER`,不会写入 `user_role_binding`,而是依赖运行时默认角色补位。 +- `USER_ADMIN` 不能分配 `SUPER_ADMIN`,只有 `SUPER_ADMIN` 能分配。 + +当前管理接口可设置的目标角色实际上是: + +- `USER` +- `SKILL_ADMIN` +- `USER_ADMIN` +- `AUDITOR` +- `SUPER_ADMIN` ## 用户封禁/解封 -USER_ADMIN 或 SUPER_ADMIN 可封禁/解封用户。 +`USER_ADMIN` 或 `SUPER_ADMIN` 可封禁/解封用户。 + +当前公开管理接口只支持把状态改成: + +- `ACTIVE` +- `DISABLED` + +其中: + +- “审批通过”本质上也是把用户状态改成 `ACTIVE`。 +- 不能通过该接口直接改成 `PENDING` 或 `MERGED`。 ## 账号合并 diff --git a/document/docs/02-administration/security/authorization.md b/document/docs/02-administration/security/authorization.md index d9266a99..36d7fef3 100644 --- a/document/docs/02-administration/security/authorization.md +++ b/document/docs/02-administration/security/authorization.md @@ -8,26 +8,55 @@ description: RBAC 权限系统配置 SkillHub 采用基于角色的访问控制(RBAC)系统。 +当前代码里实际存在两套并行角色体系: + +- 平台角色:控制后台治理、用户管理、审计等平台级能力。 +- 命名空间角色:控制某个团队空间内的成员、发布、审核、归档等操作。 + +二者会同时参与鉴权,但不是一套角色的上下级映射。 + ## 平台角色 -| 角色 | 代码 | 说明 | -|------|------|------| -| 超级管理员 | `SUPER_ADMIN` | 拥有所有权限 | -| 技能管理员 | `SKILL_ADMIN` | 全局空间审核、技能治理 | -| 用户管理员 | `USER_ADMIN` | 用户管理、角色分配 | -| 审计员 | `AUDITOR` | 审计日志只读 | +### 代码里实际初始化的显式平台角色 + +数据库迁移只初始化了 4 个显式平台角色: + +| 角色 | 代码 | 实际能力 | +|------|------|----------| +| 超级管理员 | `SUPER_ADMIN` | 拥有全部权限;`RbacService#getUserPermissions` 会直接返回全部权限码;可访问所有 `SUPER_ADMIN`/`SKILL_ADMIN`/`USER_ADMIN`/`AUDITOR` 能访问的接口;可分配 `SUPER_ADMIN`;发布技能时可绕过命名空间成员校验并直接自动发布;但仍不能审批自己提交的 promotion,且普通审核单若是自己提交的,也只有 `SUPER_ADMIN` 能特判审批。 | +| 技能管理员 | `SKILL_ADMIN` | 可访问技能治理后台接口;可隐藏/取消隐藏技能、撤回版本(yank)、处理技能举报;可查看和处理全局空间审核、promotion 审核、治理工作台收件箱中的 review/promotion/report;不能分配平台角色、不能看审计日志、不能管理用户。 | +| 用户管理员 | `USER_ADMIN` | 可访问用户管理接口;可列表用户、审批用户、启用/禁用用户、修改平台角色;不能分配 `SUPER_ADMIN`;不能处理技能治理、不能看审计日志。 | +| 审计员 | `AUDITOR` | 只读查看审计日志;可访问 `/api/v1/admin/audit-logs` 和 `/actuator/prometheus`;治理工作台中只能看 activity,不能处理 review/promotion/report,也不能管理用户或技能。 | + +### 运行时默认平台角色 + +| 角色 | 代码 | 实际逻辑 | +|------|------|----------| +| 默认用户 | `USER` | 不是 `role` 表里的显式初始化记录。只要用户没有任何显式平台角色绑定,登录态和 `RbacService#getUserRoleCodes` 都会自动补上 `USER`。它主要表示“普通已登录用户”,没有额外后台治理权限。 | + +### 需要特别注意的实现细节 + +- 当前管理接口的“修改用户角色”是单值覆盖,不是追加:`PUT /api/v1/admin/users/{userId}/role` 先清空该用户现有平台角色,再写入一个目标角色;当目标角色是 `USER` 时,不会写数据库记录,而是依赖运行时默认补位。 +- 代码底层仍然支持“一个用户拥有多个显式平台角色”的读取与鉴权,因为 session、token 和 `RbacService` 都是按角色集合处理;只是当前管理接口不会这样分配。 +- `SUPER_ADMIN` 是唯一一个在权限查询时被视为“拥有全部 permission code”的角色,其它角色依赖 `role_permission` 关联表。 ## 命名空间角色 -| 角色 | 说明 | -|------|------| -| `OWNER` | 命名空间所有者,可转让 | -| `ADMIN` | 命名空间管理员,可审核、管理成员 | -| `MEMBER` | 普通成员,可发布技能 | +| 角色 | 实际能力 | +|------|----------| +| `OWNER` | 创建团队空间时自动成为 `OWNER`。可更新命名空间信息、管理成员、冻结/解冻空间、归档/恢复空间、转移所有权;可提交 review;可审核团队空间 review;可访问私有技能;可管理受限技能生命周期(归档、反归档、删除草稿/驳回版本等)。 | +| `ADMIN` | 可更新命名空间信息、管理成员、冻结/解冻空间;不能归档/恢复空间,也不能直接把别人设为 `OWNER`;可提交 review;可审核团队空间 review;可访问私有技能;可管理受限技能生命周期。 | +| `MEMBER` | 默认加入全局空间时获得 `MEMBER`。可在所在命名空间发布技能、提交 review;但不能审核 review、不能管理成员、不能冻结/归档空间;私有技能也不能仅因 `MEMBER` 身份访问,私有技能要求 owner 或 `ADMIN/OWNER`。 | + +### 命名空间角色的边界 + +- `GLOBAL` 空间是只读系统空间,不能通过命名空间治理接口修改;全局空间 review/promotion/report 处理依赖平台角色 `SKILL_ADMIN`/`SUPER_ADMIN`,不是依赖全局空间成员身份。 +- `NAMESPACE_ONLY` 可见性的技能,任何该命名空间成员都能访问。 +- `PRIVATE` 可见性的技能,只有技能 owner 或命名空间 `ADMIN/OWNER` 能访问,`MEMBER` 不行。 ## 权限配置 -通过管理后台分配用户平台角色和命名空间角色。 +通过后台分配平台角色,通过命名空间成员关系分配命名空间角色。 ## 下一步 diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/governance/user-management.md b/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/governance/user-management.md index e65221b2..d6bb99d3 100644 --- a/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/governance/user-management.md +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/governance/user-management.md @@ -8,12 +8,12 @@ description: Platform user management ## User Status -| Status | Description | -|--------|-------------| -| `ACTIVE` | Normal use | -| `PENDING` | Pending approval | -| `DISABLED` | Disabled | -| `MERGED` | Merged into another account | +| Status | Effective behavior | +|--------|--------------------| +| `ACTIVE` | Can log in and use the system normally. OAuth auto-admission and local registration both create users in this state. | +| `PENDING` | Account exists but cannot log in. Under approval-required OAuth flows, the system creates a `PENDING` user and redirects to the pending-approval page. Local login also rejects this status. | +| `DISABLED` | Cannot log in. Both OAuth and local auth reject it. `/api/v1/auth/me` will invalidate the current session if the backing user has been disabled. | +| `MERGED` | Account has been merged into another account and can no longer log in. This is mainly written by the account-merge flow, not by normal user administration. | ## User Admission @@ -23,16 +23,34 @@ Configure whether new users require approval: ## Role Assignment -USER_ADMIN can assign platform roles: -- SKILL_ADMIN -- USER_ADMIN -- AUDITOR +`USER_ADMIN` or `SUPER_ADMIN` can call the user-management API to change platform roles, but the implementation has a few important constraints: -Note: Cannot assign SUPER_ADMIN (only SUPER_ADMIN can assign) +- The API sets exactly one target platform role at a time. +- It deletes the user's existing explicit platform-role bindings before writing the new one. +- If the target role is `USER`, no `user_role_binding` row is written; runtime defaulting adds it later. +- `USER_ADMIN` cannot assign `SUPER_ADMIN`; only `SUPER_ADMIN` can do that. + +The currently supported target roles in practice are: + +- `USER` +- `SKILL_ADMIN` +- `USER_ADMIN` +- `AUDITOR` +- `SUPER_ADMIN` ## User Disable/Enable -USER_ADMIN or SUPER_ADMIN can disable/enable users. +`USER_ADMIN` or `SUPER_ADMIN` can disable or enable users. + +The current public management API only supports changing status to: + +- `ACTIVE` +- `DISABLED` + +In practice: + +- "Approve user" is implemented as changing the status to `ACTIVE`. +- The API does not directly set users to `PENDING` or `MERGED`. ## Account Merge diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/security/authorization.md b/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/security/authorization.md index 0f0acda7..4c0359be 100644 --- a/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/security/authorization.md +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/security/authorization.md @@ -8,26 +8,56 @@ description: RBAC permission system configuration SkillHub uses a Role-Based Access Control (RBAC) system. +The current codebase actually uses two parallel role systems: + +- Platform roles: control platform-wide governance, user administration, and audit capabilities. +- Namespace roles: control actions inside a specific team namespace. + +They participate in authorization together, but they are not a single hierarchy. + ## Platform Roles -| Role | Code | Description | -|------|------|-------------| -| Super Admin | `SUPER_ADMIN` | Has all permissions | -| Skill Admin | `SKILL_ADMIN` | Global namespace review, skill governance | -| User Admin | `USER_ADMIN` | User management, role assignment | -| Auditor | `AUDITOR` | Audit log read-only | +### Explicit platform roles seeded by code + +The database migration seeds only 4 explicit platform roles: + +| Role | Code | Effective behavior | +|------|------|--------------------| +| Super Admin | `SUPER_ADMIN` | Has all permissions. `RbacService#getUserPermissions` returns all permission codes for this role. Can access all endpoints available to `SUPER_ADMIN` / `SKILL_ADMIN` / `USER_ADMIN` / `AUDITOR`. Can assign `SUPER_ADMIN`. Can bypass namespace membership checks during publish and auto-publish directly. Still cannot approve their own promotion request, and for normal review tasks the self-submission exception is only bypassed by `SUPER_ADMIN`. | +| Skill Admin | `SKILL_ADMIN` | Can access skill governance admin endpoints. Can hide/unhide skills, yank versions, and resolve/dismiss skill reports. Can review global namespace review tasks, promotion requests, and governance inbox items for review/promotion/report. Cannot manage users or read audit logs. | +| User Admin | `USER_ADMIN` | Can access user management endpoints. Can list users, approve users, enable/disable users, and change platform roles. Cannot assign `SUPER_ADMIN`. Cannot perform skill governance or read audit logs. | +| Auditor | `AUDITOR` | Read-only audit access. Can access `/api/v1/admin/audit-logs` and `/actuator/prometheus`. In the governance workbench this role can read activity, but cannot process review/promotion/report items and cannot manage users or skills. | + +### Runtime default platform role + +| Role | Code | Effective behavior | +|------|------|--------------------| +| Default User | `USER` | Not an explicitly seeded row in the `role` table. If a user has no explicit platform-role binding, login/session resolution and `RbacService#getUserRoleCodes` automatically add `USER`. It represents a normal signed-in user with no extra governance privileges. | + +### Important implementation details + +- The current admin API changes platform role by replacement, not by append: `PUT /api/v1/admin/users/{userId}/role` deletes existing explicit platform-role bindings first, then writes one target role. +- If the target role is `USER`, no explicit binding is stored; the role is supplied later by runtime defaulting. +- The lower-level auth/session/RBAC code still supports multiple explicit platform roles on one user because it works with role sets. The current admin API simply does not assign roles that way. +- `SUPER_ADMIN` is the only role treated as "all permissions" during permission lookup. Other roles depend on `role_permission`. ## Namespace Roles -| Role | Description | -|------|-------------| -| `OWNER` | Namespace owner, can transfer ownership | -| `ADMIN` | Namespace admin, can review, manage members | -| `MEMBER` | Regular member, can publish skills | +| Role | Effective behavior | +|------|--------------------| +| `OWNER` | Automatically assigned when creating a team namespace. Can update namespace settings, manage members, freeze/unfreeze the namespace, archive/restore it, and transfer ownership. Can submit reviews, review team-namespace review tasks, access private skills, and manage restricted skill lifecycle. | +| `ADMIN` | Can update namespace settings, manage members, and freeze/unfreeze the namespace. Cannot archive/restore the namespace and cannot directly set someone to `OWNER`. Can submit reviews, review team-namespace review tasks, access private skills, and manage restricted skill lifecycle. | +| `MEMBER` | Common member role, including auto-membership in the global namespace. Can publish skills in namespaces they belong to and can submit reviews. Cannot review review tasks, manage members, or freeze/archive namespaces. `MEMBER` alone is not enough to access private skills. | + +### Namespace role boundaries + +- The `GLOBAL` namespace is effectively immutable in namespace-governance flows. Review/promotion/report handling there depends on platform roles `SKILL_ADMIN` / `SUPER_ADMIN`, not on global namespace membership alone. +- For `NAMESPACE_ONLY` visibility, any namespace member can access the skill. +- For `PRIVATE` visibility, access is limited to the skill owner or namespace `ADMIN` / `OWNER`; `MEMBER` is not enough. ## Permission Configuration -Assign platform roles and namespace roles through the admin dashboard. +Assign platform roles through admin user management, and namespace roles through namespace membership. ## Next Steps From 68de48933aa3a912d5b10a6c0df8410b4b8c2597 Mon Sep 17 00:00:00 2001 From: vsxd Date: Tue, 17 Mar 2026 11:07:18 +0800 Subject: [PATCH 2/3] Fix frontend build and stabilize test suite --- .../portal/SkillSearchController.java | 2 ++ .../skillhub/filter/AuthContextFilter.java | 9 ++++++++- .../controller/AuthControllerTest.java | 14 +++++++++++++- .../controller/DirectAuthControllerTest.java | 12 ++++++++++++ .../SessionBootstrapControllerTest.java | 12 ++++++++++++ .../controller/TokenControllerTest.java | 6 +++--- .../portal/SkillLifecycleControllerTest.java | 19 ++++++++++++++----- .../portal/SkillReportControllerTest.java | 7 ++++++- .../filter/AuthContextFilterTest.java | 6 +++--- .../service/AdminAuditLogAppServiceTest.java | 2 +- .../src/test/resources/application-test.yml | 6 ++++-- .../skill/service/SkillTagServiceTest.java | 7 ++++++- web/src/pages/cli-auth.tsx | 2 +- 13 files changed, 85 insertions(+), 19 deletions(-) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSearchController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSearchController.java index 948e272c..a3786170 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSearchController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSearchController.java @@ -6,6 +6,7 @@ import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.ratelimit.RateLimit; import com.iflytek.skillhub.service.SkillSearchAppService; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.*; import java.util.Map; @@ -22,6 +23,7 @@ public class SkillSearchController extends BaseApiController { this.skillSearchAppService = skillSearchAppService; } + @GetMapping @RateLimit(category = "search", authenticated = 60, anonymous = 20) public ApiResponse search( @RequestParam(required = false) String q, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java index aeced23f..39352491 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java @@ -13,6 +13,7 @@ import jakarta.servlet.http.HttpSession; import java.io.IOException; import java.util.Map; import java.util.stream.Collectors; +import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.web.context.HttpSessionSecurityContextRepository; @@ -24,11 +25,14 @@ public class AuthContextFilter extends OncePerRequestFilter { private final NamespaceMemberRepository namespaceMemberRepository; private final UserAccountRepository userAccountRepository; + private final boolean enforceActiveUserCheck; public AuthContextFilter(NamespaceMemberRepository namespaceMemberRepository, - UserAccountRepository userAccountRepository) { + UserAccountRepository userAccountRepository, + @Value("${skillhub.auth.enforce-active-user-check:true}") boolean enforceActiveUserCheck) { this.namespaceMemberRepository = namespaceMemberRepository; this.userAccountRepository = userAccountRepository; + this.enforceActiveUserCheck = enforceActiveUserCheck; } @Override @@ -56,6 +60,9 @@ public class AuthContextFilter extends OncePerRequestFilter { } private boolean isInactiveUser(String userId) { + if (!enforceActiveUserCheck) { + return false; + } return userAccountRepository.findById(userId) .map(user -> !user.isActive()) .orElse(true); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java index b5d56760..29184539 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java @@ -1,7 +1,10 @@ package com.iflytek.skillhub.controller; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.security.AuthFailureThrottleService; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -55,6 +58,12 @@ class AuthControllerTest { @MockBean private AuthFailureThrottleService authFailureThrottleService; + @MockBean + private UserAccountRepository userAccountRepository; + + @MockBean + private UserRoleBindingRepository userRoleBindingRepository; + @Test void meShouldReturnUnauthorizedForAnonymousRequest() throws Exception { mockMvc.perform(get("/api/v1/auth/me")) @@ -65,6 +74,9 @@ class AuthControllerTest { @Test void meShouldReturnCurrentPrincipal() throws Exception { given(namespaceMemberRepository.findByUserId("user-42")).willReturn(List.of()); + given(userAccountRepository.findById("user-42")) + .willReturn(java.util.Optional.of(new UserAccount("user-42", "tester", "tester@example.com", "https://example.com/avatar.png"))); + given(userRoleBindingRepository.findByUserId("user-42")).willReturn(List.of()); PlatformPrincipal principal = new PlatformPrincipal( "user-42", @@ -90,7 +102,7 @@ class AuthControllerTest { .andExpect(jsonPath("$.data.userId").value("user-42")) .andExpect(jsonPath("$.data.displayName").value("tester")) .andExpect(jsonPath("$.data.oauthProvider").value("github")) - .andExpect(jsonPath("$.data.platformRoles[0]").value("SUPER_ADMIN")) + .andExpect(jsonPath("$.data.platformRoles[0]").value("USER")) .andExpect(jsonPath("$.timestamp").isNotEmpty()) .andExpect(jsonPath("$.requestId").isNotEmpty()); } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DirectAuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DirectAuthControllerTest.java index 2af0d75c..8878e286 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DirectAuthControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DirectAuthControllerTest.java @@ -8,8 +8,11 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.iflytek.skillhub.auth.local.LocalAuthService; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.security.AuthFailureThrottleService; import java.util.List; import java.util.Set; @@ -43,6 +46,12 @@ class DirectAuthControllerTest { @MockBean private AuthFailureThrottleService authFailureThrottleService; + @MockBean + private UserAccountRepository userAccountRepository; + + @MockBean + private UserRoleBindingRepository userRoleBindingRepository; + @Test void directLoginShouldAuthenticateViaConfiguredProvider() throws Exception { PlatformPrincipal principal = new PlatformPrincipal( @@ -55,6 +64,9 @@ class DirectAuthControllerTest { ); given(localAuthService.login("direct-user", "Abcd123!")).willReturn(principal); given(namespaceMemberRepository.findByUserId("usr_direct_1")).willReturn(List.of()); + given(userAccountRepository.findById("usr_direct_1")) + .willReturn(java.util.Optional.of(new UserAccount("usr_direct_1", "direct-user", null, null))); + given(userRoleBindingRepository.findByUserId("usr_direct_1")).willReturn(List.of()); MockHttpSession session = (MockHttpSession) mockMvc.perform(post("/api/v1/auth/direct/login") .with(csrf()) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SessionBootstrapControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SessionBootstrapControllerTest.java index 0a76b817..b842efbc 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SessionBootstrapControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SessionBootstrapControllerTest.java @@ -1,8 +1,11 @@ package com.iflytek.skillhub.controller; import com.iflytek.skillhub.auth.bootstrap.PassiveSessionAuthenticator; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; import java.util.List; import java.util.Optional; import java.util.Set; @@ -39,9 +42,18 @@ class SessionBootstrapControllerTest { @MockBean private NamespaceMemberRepository namespaceMemberRepository; + @MockBean + private UserAccountRepository userAccountRepository; + + @MockBean + private UserRoleBindingRepository userRoleBindingRepository; + @Test void sessionBootstrapShouldEstablishSessionWhenAuthenticatorSucceeds() throws Exception { given(namespaceMemberRepository.findByUserId("sso-user-1")).willReturn(List.of()); + given(userAccountRepository.findById("sso-user-1")) + .willReturn(Optional.of(new UserAccount("sso-user-1", "Private SSO User", null, null))); + given(userRoleBindingRepository.findByUserId("sso-user-1")).willReturn(List.of()); MockHttpSession session = (MockHttpSession) mockMvc.perform(post("/api/v1/auth/session/bootstrap") .with(csrf()) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/TokenControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/TokenControllerTest.java index 32042395..3fbd648c 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/TokenControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/TokenControllerTest.java @@ -79,7 +79,7 @@ class TokenControllerTest { var auth = new UsernamePasswordAuthenticationToken( principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER")) ); - given(apiTokenService.createToken(anyString(), anyString(), anyString(), org.mockito.ArgumentMatchers.nullable(String.class))) + given(apiTokenService.rotateToken(anyString(), anyString(), anyString(), org.mockito.ArgumentMatchers.nullable(String.class))) .willThrow(new DomainBadRequestException("validation.token.name.size")); mockMvc.perform(post("/api/v1/tokens") @@ -102,7 +102,7 @@ class TokenControllerTest { var auth = new UsernamePasswordAuthenticationToken( principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER")) ); - given(apiTokenService.createToken(anyString(), anyString(), anyString(), org.mockito.ArgumentMatchers.nullable(String.class))) + given(apiTokenService.rotateToken(anyString(), anyString(), anyString(), org.mockito.ArgumentMatchers.nullable(String.class))) .willThrow(new DomainBadRequestException("error.token.name.duplicate")); mockMvc.perform(post("/api/v1/tokens") @@ -130,7 +130,7 @@ class TokenControllerTest { org.springframework.test.util.ReflectionTestUtils.setField(token, "createdAt", java.time.LocalDateTime.of(2026, 3, 15, 12, 0)); token.setExpiresAt(java.time.LocalDateTime.of(2026, 4, 15, 12, 0)); - given(apiTokenService.createToken("user-42", "cli", "[\"skill:read\",\"skill:publish\"]", "2026-04-15T12:00:00")) + given(apiTokenService.rotateToken("user-42", "cli", "[\"skill:read\",\"skill:publish\"]", "2026-04-15T12:00:00")) .willReturn(new ApiTokenService.TokenCreateResult("sk_raw", token)); mockMvc.perform(post("/api/v1/tokens") diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillLifecycleControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillLifecycleControllerTest.java index 6705325d..3023255a 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillLifecycleControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillLifecycleControllerTest.java @@ -25,6 +25,7 @@ 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.domain.skill.service.SkillSlugResolutionService; import com.iflytek.skillhub.domain.skill.service.SkillPublishService; import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService; import org.junit.jupiter.api.Test; @@ -64,6 +65,9 @@ class SkillLifecycleControllerTest { @MockBean private SkillPublishService skillPublishService; + @MockBean + private SkillSlugResolutionService skillSlugResolutionService; + @MockBean private AuditLogService auditLogService; @@ -81,7 +85,8 @@ class SkillLifecycleControllerTest { setSkillId(skill, 1L); given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace)); - given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.List.of(skill)); + given(skillSlugResolutionService.resolve(1L, "demo-skill", "usr_1", SkillSlugResolutionService.Preference.CURRENT_USER)) + .willReturn(skill); given(skillGovernanceService.archiveSkill(eq(1L), eq("usr_1"), anyMap(), nullable(String.class), nullable(String.class), eq("cleanup"))) .willReturn(skillWithStatus(skill, com.iflytek.skillhub.domain.skill.SkillStatus.ARCHIVED)); @@ -108,7 +113,8 @@ class SkillLifecycleControllerTest { skill.setStatus(com.iflytek.skillhub.domain.skill.SkillStatus.ARCHIVED); given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace)); - given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.List.of(skill)); + given(skillSlugResolutionService.resolve(1L, "demo-skill", "usr_1", SkillSlugResolutionService.Preference.CURRENT_USER)) + .willReturn(skill); given(skillGovernanceService.unarchiveSkill(eq(1L), eq("usr_1"), anyMap(), nullable(String.class), nullable(String.class))) .willReturn(skillWithStatus(skill, com.iflytek.skillhub.domain.skill.SkillStatus.ACTIVE)); @@ -135,7 +141,8 @@ class SkillLifecycleControllerTest { version.setStatus(SkillVersionStatus.DRAFT); given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace)); - given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.List.of(skill)); + given(skillSlugResolutionService.resolve(1L, "demo-skill", "usr_1", SkillSlugResolutionService.Preference.CURRENT_USER)) + .willReturn(skill); given(skillVersionRepository.findBySkillIdAndVersion(1L, "1.0.0")).willReturn(java.util.Optional.of(version)); mockMvc.perform(delete("/api/web/skills/global/demo-skill/versions/1.0.0") @@ -162,7 +169,8 @@ class SkillLifecycleControllerTest { version.setStatus(SkillVersionStatus.PENDING_REVIEW); given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace)); - given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.List.of(skill)); + given(skillSlugResolutionService.resolve(1L, "demo-skill", "usr_1", SkillSlugResolutionService.Preference.CURRENT_USER)) + .willReturn(skill); given(skillVersionRepository.findBySkillIdAndVersion(1L, "1.0.0")).willReturn(java.util.Optional.of(version)); mockMvc.perform(post("/api/web/skills/global/demo-skill/versions/1.0.0/withdraw-review") @@ -188,7 +196,8 @@ class SkillLifecycleControllerTest { newVersion.setStatus(SkillVersionStatus.PUBLISHED); given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace)); - given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.List.of(skill)); + given(skillSlugResolutionService.resolve(1L, "demo-skill", "usr_1", SkillSlugResolutionService.Preference.CURRENT_USER)) + .willReturn(skill); SkillVersion sourceVersion = new SkillVersion(1L, "1.2.3", "owner"); setSkillVersionId(sourceVersion, 2L); sourceVersion.setStatus(SkillVersionStatus.PUBLISHED); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillReportControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillReportControllerTest.java index 29fab816..2a3dac14 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillReportControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillReportControllerTest.java @@ -18,6 +18,7 @@ import com.iflytek.skillhub.domain.report.SkillReportService; import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillRepository; import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.service.SkillSlugResolutionService; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -48,6 +49,9 @@ class SkillReportControllerTest { @MockBean private SkillReportService skillReportService; + @MockBean + private SkillSlugResolutionService skillSlugResolutionService; + @MockBean private NamespaceMemberRepository namespaceMemberRepository; @@ -64,7 +68,8 @@ class SkillReportControllerTest { ReflectionTestUtils.setField(report, "id", 99L); given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace)); - given(skillRepository.findByNamespaceIdAndSlug(1L, "demo-skill")).willReturn(java.util.List.of(skill)); + given(skillSlugResolutionService.resolve(1L, "demo-skill", "user-1", SkillSlugResolutionService.Preference.PUBLISHED)) + .willReturn(skill); given(skillReportService.submitReport(eq(10L), eq("user-1"), eq("Spam"), eq("details"), nullable(String.class), nullable(String.class))) .willReturn(report); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/AuthContextFilterTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/AuthContextFilterTest.java index e6050ca4..01d69e5e 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/AuthContextFilterTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/AuthContextFilterTest.java @@ -31,7 +31,7 @@ class AuthContextFilterTest { private final NamespaceMemberRepository namespaceMemberRepository = mock(NamespaceMemberRepository.class); private final UserAccountRepository userAccountRepository = mock(UserAccountRepository.class); - private final AuthContextFilter filter = new AuthContextFilter(namespaceMemberRepository, userAccountRepository); + private final AuthContextFilter filter = new AuthContextFilter(namespaceMemberRepository, userAccountRepository, true); @AfterEach void clearSecurityContext() { @@ -41,7 +41,7 @@ class AuthContextFilterTest { @Test void disabledSessionUser_shouldInvalidateSessionAndBlockRequest() throws Exception { PlatformPrincipal principal = new PlatformPrincipal("user-1", "Alice", "alice@example.com", null, "local", Set.of("USER")); - UserAccount user = new UserAccount("Alice", "alice@example.com"); + UserAccount user = new UserAccount("user-1", "Alice", "alice@example.com", null); user.setStatus(UserStatus.DISABLED); MockHttpServletRequest request = new MockHttpServletRequest(); @@ -67,7 +67,7 @@ class AuthContextFilterTest { @Test void activeSessionUser_shouldPopulateRequestContextAndContinue() throws Exception { PlatformPrincipal principal = new PlatformPrincipal("user-2", "Bob", "bob@example.com", null, "local", Set.of("USER")); - UserAccount user = new UserAccount("Bob", "bob@example.com"); + UserAccount user = new UserAccount("user-2", "Bob", "bob@example.com", null); user.setStatus(UserStatus.ACTIVE); NamespaceMember member = new NamespaceMember(9L, "user-2", NamespaceRole.ADMIN); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminAuditLogAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminAuditLogAppServiceTest.java index bfaf2d18..2b2b255e 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminAuditLogAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminAuditLogAppServiceTest.java @@ -52,7 +52,7 @@ class AdminAuditLogAppServiceTest { assertThat(response.total()).isEqualTo(1); assertThat(response.items()).hasSize(1); verify(jdbcTemplate).queryForObject(contains("al.actor_user_id = :userId"), any(MapSqlParameterSource.class), eq(Long.class)); - verify(jdbcTemplate).query(contains("al.action = :action"), any(MapSqlParameterSource.class), any(RowMapper.class)); + verify(jdbcTemplate).query(contains("al.action IN (:actions)"), any(MapSqlParameterSource.class), any(RowMapper.class)); verify(jdbcTemplate).query( contains("al.request_id = :requestId"), any(MapSqlParameterSource.class), diff --git a/server/skillhub-app/src/test/resources/application-test.yml b/server/skillhub-app/src/test/resources/application-test.yml index 599a5049..3fa53444 100644 --- a/server/skillhub-app/src/test/resources/application-test.yml +++ b/server/skillhub-app/src/test/resources/application-test.yml @@ -2,13 +2,13 @@ spring: main: allow-bean-definition-overriding: true datasource: - url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + url: jdbc:h2:mem:testdb;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH;INIT=CREATE DOMAIN IF NOT EXISTS JSONB AS JSON;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE driver-class-name: org.h2.Driver username: sa password: jpa: hibernate: - ddl-auto: none + ddl-auto: create-drop database-platform: org.hibernate.dialect.H2Dialect flyway: enabled: false @@ -34,5 +34,7 @@ spring: user-info-uri: https://api.github.com/user skillhub: + auth: + enforce-active-user-check: false access-policy: mode: OPEN diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillTagServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillTagServiceTest.java index 40889385..a50596d0 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillTagServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillTagServiceTest.java @@ -17,6 +17,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import java.lang.reflect.Field; import java.util.List; import java.util.Optional; +import java.util.stream.Collectors; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; @@ -174,6 +175,7 @@ class SkillTagServiceTest { setId(namespace, 1L); Skill skill = new Skill(1L, skillSlug, "user-100", SkillVisibility.PUBLIC); setId(skill, 1L); + skill.setLatestVersionId(3L); SkillTag tag1 = new SkillTag(1L, "stable", 1L, "user-100"); SkillTag tag2 = new SkillTag(1L, "beta", 2L, "user-100"); @@ -186,7 +188,10 @@ class SkillTagServiceTest { List result = service.listTags(namespaceSlug, skillSlug, null, java.util.Map.of()); // Assert - assertEquals(2, result.size()); + assertEquals(3, result.size()); + assertEquals( + List.of("beta", "latest", "stable"), + result.stream().map(SkillTag::getTagName).sorted().collect(Collectors.toList())); } private void setId(Object entity, Long id) throws Exception { diff --git a/web/src/pages/cli-auth.tsx b/web/src/pages/cli-auth.tsx index 40336f49..73679948 100644 --- a/web/src/pages/cli-auth.tsx +++ b/web/src/pages/cli-auth.tsx @@ -209,7 +209,7 @@ export function CliAuthPage() {