Merge remote-tracking branch 'origin/main' into feature/project-fixbug

This commit is contained in:
yun-zhi-ztl 2026-03-17 11:37:22 +08:00
commit f6aaa6f587
18 changed files with 304 additions and 67 deletions

76
.github/workflows/pr-tests.yml vendored Normal file
View file

@ -0,0 +1,76 @@
name: PR Tests
on:
pull_request:
types:
- opened
- synchronize
- reopened
- ready_for_review
workflow_dispatch:
concurrency:
group: pr-tests-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
web-tests:
name: Web Build And Test
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
runs-on: ubuntu-latest
defaults:
run:
working-directory: web
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
cache-dependency-path: web/pnpm-lock.yaml
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build frontend
run: pnpm build
- name: Run frontend unit tests
run: pnpm test
server-tests:
name: Server Unit Tests
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
runs-on: ubuntu-latest
defaults:
run:
working-directory: server
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
cache: maven
- name: Ensure Maven wrapper is executable
run: chmod +x mvnw
- name: Run backend unit tests
run: ./mvnw test

View file

@ -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`
## 账号合并

View file

@ -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` 不行。
## 权限配置
通过管理后台分配用户平台角色和命名空间角色。
通过后台分配平台角色,通过命名空间成员关系分配命名空间角色。
## 下一步

View file

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

View file

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

View file

@ -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<SkillSearchAppService.SearchResponse> search(
@RequestParam(required = false) String q,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -209,7 +209,7 @@ export function CliAuthPage() {
<Button
className="w-full"
onClick={() => {
const returnTo = `/cli/auth?${searchParams.toString()}`
const returnTo = `/cli/auth?${ORIGINAL_PARAMS.toString()}`
navigate({ to: '/login', search: { returnTo } })
}}
>