Merge pull request #2 from iflytek/feature/project-review

review: fix device auth, review permissions, and publish state flow
This commit is contained in:
yun-zhi-ztl 2026-03-14 03:09:33 -07:00 committed by GitHub
commit 1407e335c2
59 changed files with 2983 additions and 1084 deletions

3
.gitignore vendored
View file

@ -67,4 +67,5 @@ __pycache__/
# Superpowers (AI planning artifacts) # Superpowers (AI planning artifacts)
docs/superpowers/ docs/superpowers/
CLAUDE.md docs/review/
CLAUDE.md

View file

@ -6,7 +6,18 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SERVER_DIR="$ROOT_DIR/server" SERVER_DIR="$ROOT_DIR/server"
WEB_DIR="$ROOT_DIR/web" WEB_DIR="$ROOT_DIR/web"
API_LOG="${TMPDIR:-/tmp}/skillhub-openapi-check.log" API_LOG="${TMPDIR:-/tmp}/skillhub-openapi-check.log"
BUILD_LOG="${TMPDIR:-/tmp}/skillhub-openapi-build.log"
SERVER_PID="" SERVER_PID=""
OPENAPI_URL="http://127.0.0.1:8080/v3/api-docs"
print_log_tail() {
local log_file="$1"
if [[ -f "$log_file" ]]; then
echo "--- Last 50 lines of $log_file ---" >&2
tail -n 50 "$log_file" >&2 || true
fi
}
cleanup() { cleanup() {
if [[ -n "$SERVER_PID" ]] && kill -0 "$SERVER_PID" 2>/dev/null; then if [[ -n "$SERVER_PID" ]] && kill -0 "$SERVER_PID" 2>/dev/null; then
@ -21,6 +32,15 @@ trap cleanup EXIT
cd "$ROOT_DIR" cd "$ROOT_DIR"
docker compose up -d --wait postgres redis docker compose up -d --wait postgres redis
(
cd "$SERVER_DIR"
./mvnw -pl skillhub-app -am -DskipTests install
) >"$BUILD_LOG" 2>&1 || {
echo "Failed to prepare backend modules. See $BUILD_LOG" >&2
print_log_tail "$BUILD_LOG"
exit 1
}
( (
cd "$SERVER_DIR" cd "$SERVER_DIR"
SPRING_PROFILES_ACTIVE=local ./mvnw -pl skillhub-app spring-boot:run SPRING_PROFILES_ACTIVE=local ./mvnw -pl skillhub-app spring-boot:run
@ -28,14 +48,22 @@ docker compose up -d --wait postgres redis
SERVER_PID=$! SERVER_PID=$!
for _ in $(seq 1 90); do for _ in $(seq 1 90); do
if curl -fsS "http://127.0.0.1:8080/v3/api-docs" >/dev/null 2>&1; then if curl -fsS "$OPENAPI_URL" >/dev/null 2>&1; then
break break
fi fi
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
echo "Backend exited before exposing /v3/api-docs. See $API_LOG" >&2
print_log_tail "$API_LOG"
exit 1
fi
sleep 2 sleep 2
done done
if ! curl -fsS "http://127.0.0.1:8080/v3/api-docs" >/dev/null 2>&1; then if ! curl -fsS "$OPENAPI_URL" >/dev/null 2>&1; then
echo "Backend did not expose /v3/api-docs. See $API_LOG" >&2 echo "Backend did not expose /v3/api-docs. See $API_LOG" >&2
print_log_tail "$API_LOG"
exit 1 exit 1
fi fi

View file

@ -2,28 +2,29 @@ package com.iflytek.skillhub.compat;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.compat.dto.ClawHubPublishResponse; import com.iflytek.skillhub.compat.dto.ClawHubPublishResponse;
import com.iflytek.skillhub.compat.dto.ClawHubSkillItem;
import com.iflytek.skillhub.compat.dto.ClawHubResolveResponse;
import com.iflytek.skillhub.compat.dto.ClawHubSearchResponse;
import com.iflytek.skillhub.compat.dto.ClawHubWhoamiResponse;
import com.iflytek.skillhub.controller.support.ZipPackageExtractor; import com.iflytek.skillhub.controller.support.ZipPackageExtractor;
import com.iflytek.skillhub.domain.audit.AuditLogService; import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.skill.service.SkillPublishService; import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
import com.iflytek.skillhub.domain.skill.service.SkillQueryService; import com.iflytek.skillhub.domain.skill.service.SkillQueryService;
import com.iflytek.skillhub.compat.dto.ClawHubResolveResponse;
import com.iflytek.skillhub.compat.dto.ClawHubSearchResponse;
import com.iflytek.skillhub.compat.dto.ClawHubSkillItem;
import com.iflytek.skillhub.compat.dto.ClawHubWhoamiResponse;
import com.iflytek.skillhub.service.SkillSearchAppService; import com.iflytek.skillhub.service.SkillSearchAppService;
import java.io.IOException; import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.slf4j.MDC;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import org.slf4j.MDC;
@RestController @RestController
@RequestMapping("/api/compat/v1") @RequestMapping("/api/compat/v1")
@ -51,18 +52,31 @@ public class ClawHubCompatController {
} }
@GetMapping("/search") @GetMapping("/search")
public ClawHubSearchResponse search(@RequestParam String q, public ClawHubSearchResponse search(
@RequestAttribute(value = "userId", required = false) String userId, @RequestParam String q,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) { @RequestParam(defaultValue = "0") int page,
var result = skillSearchAppService.search(q, null, "relevance", 0, 20, userId, userNsRoles != null ? userNsRoles : Map.of()); @RequestParam(defaultValue = "20") int limit,
return new ClawHubSearchResponse(result.items().stream() @RequestAttribute(value = "userId", required = false) String userId,
.map(item -> new ClawHubSkillItem( @RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
mapper.toCanonical(item.namespace(), item.slug()), SkillSearchAppService.SearchResponse response = skillSearchAppService.search(
item.summary(), q,
item.latestVersion(), null,
item.starCount() q == null || q.isBlank() ? "newest" : "relevance",
)) page,
.toList()); limit,
userId,
userNsRoles
);
List<ClawHubSkillItem> items = response.items().stream()
.map(item -> new ClawHubSkillItem(
mapper.toCanonical(item.namespace(), item.slug()),
item.summary(),
item.latestVersion(),
item.starCount()))
.toList();
return new ClawHubSearchResponse(items);
} }
@GetMapping("/resolve/{canonicalSlug}") @GetMapping("/resolve/{canonicalSlug}")
@ -72,14 +86,14 @@ public class ClawHubCompatController {
@RequestAttribute(value = "userId", required = false) String userId, @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) {
SkillCoordinate coord = mapper.fromCanonical(canonicalSlug); SkillCoordinate coord = mapper.fromCanonical(canonicalSlug);
var resolved = skillQueryService.resolveVersion( SkillQueryService.ResolvedVersionDTO resolved = skillQueryService.resolveVersion(
coord.namespace(), coord.namespace(),
coord.slug(), coord.slug(),
"latest".equals(version) ? null : version, "latest".equals(version) ? null : version,
"latest".equals(version) ? "latest" : null, "latest".equals(version) ? "latest" : null,
null, null,
userId, userId,
userNsRoles != null ? userNsRoles : Map.of() userNsRoles != null ? userNsRoles : Map.of()
); );
return new ClawHubResolveResponse( return new ClawHubResolveResponse(
canonicalSlug, canonicalSlug,
@ -93,39 +107,39 @@ public class ClawHubCompatController {
@RequestParam(defaultValue = "latest") String version) { @RequestParam(defaultValue = "latest") String version) {
SkillCoordinate coord = mapper.fromCanonical(canonicalSlug); SkillCoordinate coord = mapper.fromCanonical(canonicalSlug);
String location = "latest".equals(version) String location = "latest".equals(version)
? "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/download" ? "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/download"
: "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/versions/" + version + "/download"; : "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/versions/" + version + "/download";
return ResponseEntity.status(HttpStatus.FOUND) return ResponseEntity.status(HttpStatus.FOUND)
.header(HttpHeaders.LOCATION, location) .header(HttpHeaders.LOCATION, location)
.build(); .build();
} }
@PostMapping("/publish") @PostMapping("/publish")
public ClawHubPublishResponse publish(@RequestParam("file") MultipartFile file, public ClawHubPublishResponse publish(@RequestParam("file") MultipartFile file,
@RequestParam("namespace") String namespace, @RequestParam("namespace") String namespace,
@AuthenticationPrincipal PlatformPrincipal principal, @AuthenticationPrincipal PlatformPrincipal principal,
jakarta.servlet.http.HttpServletRequest request) throws IOException { HttpServletRequest request) throws IOException {
var result = skillPublishService.publishFromEntries( SkillPublishService.PublishResult result = skillPublishService.publishFromEntries(
namespace, namespace,
zipPackageExtractor.extract(file), zipPackageExtractor.extract(file),
principal.userId(), principal.userId(),
SkillVisibility.PUBLIC, SkillVisibility.PUBLIC,
principal.platformRoles() principal.platformRoles()
); );
auditLogService.record( auditLogService.record(
principal.userId(), principal.userId(),
"COMPAT_PUBLISH", "COMPAT_PUBLISH",
"SKILL_VERSION", "SKILL_VERSION",
result.version().getId(), result.version().getId(),
MDC.get("requestId"), MDC.get("requestId"),
request.getRemoteAddr(), request.getRemoteAddr(),
request.getHeader("User-Agent"), request.getHeader("User-Agent"),
"{\"namespace\":\"" + namespace + "\"}" "{\"namespace\":\"" + namespace + "\"}"
); );
return new ClawHubPublishResponse( return new ClawHubPublishResponse(
mapper.toCanonical(namespace, result.slug()), mapper.toCanonical(namespace, result.slug()),
result.version().getVersion(), result.version().getVersion(),
result.version().getStatus().name() result.version().getStatus().name()
); );
} }

View file

@ -1,39 +1,41 @@
package com.iflytek.skillhub.controller; package com.iflytek.skillhub.controller;
import com.iflytek.skillhub.controller.support.SkillPackageArchiveExtractor;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.skill.validation.PackageEntry; import com.iflytek.skillhub.domain.skill.validation.PackageEntry;
import com.iflytek.skillhub.domain.skill.validation.SkillPackageValidator; import com.iflytek.skillhub.domain.skill.validation.SkillPackageValidator;
import com.iflytek.skillhub.domain.skill.validation.ValidationResult; import com.iflytek.skillhub.domain.skill.validation.ValidationResult;
import com.iflytek.skillhub.domain.skill.service.SkillQueryService;
import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.CliWhoamiResponse; import com.iflytek.skillhub.dto.CliWhoamiResponse;
import com.iflytek.skillhub.dto.ResolveVersionResponse; import com.iflytek.skillhub.dto.ResolveVersionResponse;
import com.iflytek.skillhub.dto.SkillCheckResponse; import com.iflytek.skillhub.dto.SkillCheckResponse;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.skill.service.SkillQueryService;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import com.iflytek.skillhub.exception.UnauthorizedException; import com.iflytek.skillhub.exception.UnauthorizedException;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import java.io.IOException; import java.io.IOException;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.zip.ZipEntry; import java.util.Map;
import java.util.zip.ZipInputStream;
@RestController @RestController
@RequestMapping("/api/v1/cli") @RequestMapping("/api/v1/cli")
public class CliController extends BaseApiController { public class CliController extends BaseApiController {
private final SkillPackageValidator skillPackageValidator; private final SkillPackageValidator skillPackageValidator;
private final SkillPackageArchiveExtractor skillPackageArchiveExtractor;
private final SkillQueryService skillQueryService; private final SkillQueryService skillQueryService;
public CliController(ApiResponseFactory responseFactory, public CliController(ApiResponseFactory responseFactory,
SkillPackageValidator skillPackageValidator, SkillPackageValidator skillPackageValidator,
SkillPackageArchiveExtractor skillPackageArchiveExtractor,
SkillQueryService skillQueryService) { SkillQueryService skillQueryService) {
super(responseFactory); super(responseFactory);
this.skillPackageValidator = skillPackageValidator; this.skillPackageValidator = skillPackageValidator;
this.skillPackageArchiveExtractor = skillPackageArchiveExtractor;
this.skillQueryService = skillQueryService; this.skillQueryService = skillQueryService;
} }
@ -48,7 +50,18 @@ public class CliController extends BaseApiController {
@PostMapping("/check") @PostMapping("/check")
public ApiResponse<SkillCheckResponse> check(@RequestParam("file") MultipartFile file) throws IOException { public ApiResponse<SkillCheckResponse> check(@RequestParam("file") MultipartFile file) throws IOException {
List<PackageEntry> entries = extractZipEntries(file); List<PackageEntry> entries;
try {
entries = skillPackageArchiveExtractor.extract(file);
} catch (IllegalArgumentException e) {
SkillCheckResponse response = new SkillCheckResponse(
false,
List.of(e.getMessage()),
0,
0L
);
return ok("response.success.validated", response);
}
ValidationResult result = skillPackageValidator.validate(entries); ValidationResult result = skillPackageValidator.validate(entries);
SkillCheckResponse response = new SkillCheckResponse( SkillCheckResponse response = new SkillCheckResponse(
@ -68,56 +81,25 @@ public class CliController extends BaseApiController {
@RequestParam(required = false) String tag, @RequestParam(required = false) String tag,
@RequestParam(required = false) String hash, @RequestParam(required = false) String hash,
@RequestAttribute(value = "userId", required = false) String userId, @RequestAttribute(value = "userId", required = false) String userId,
@RequestAttribute(value = "userNsRoles", required = false) java.util.Map<Long, NamespaceRole> userNsRoles) { @RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
SkillQueryService.ResolvedVersionDTO resolved = skillQueryService.resolveVersion( SkillQueryService.ResolvedVersionDTO resolved = skillQueryService.resolveVersion(
namespace, namespace,
slug, slug,
version, version,
tag, tag,
hash, hash,
userId, userId,
userNsRoles != null ? userNsRoles : java.util.Map.of() userNsRoles != null ? userNsRoles : Map.of()
); );
return ok("response.success.read", new ResolveVersionResponse( return ok("response.success.read", new ResolveVersionResponse(
resolved.skillId(), resolved.skillId(),
resolved.namespace(), resolved.namespace(),
resolved.slug(), resolved.slug(),
resolved.version(), resolved.version(),
resolved.versionId(), resolved.versionId(),
resolved.fingerprint(), resolved.fingerprint(),
resolved.matched(), resolved.matched(),
resolved.downloadUrl() resolved.downloadUrl()
)); ));
} }
private List<PackageEntry> extractZipEntries(MultipartFile file) throws IOException {
List<PackageEntry> entries = new ArrayList<>();
try (ZipInputStream zis = new ZipInputStream(file.getInputStream())) {
ZipEntry zipEntry;
while ((zipEntry = zis.getNextEntry()) != null) {
if (!zipEntry.isDirectory()) {
byte[] content = zis.readAllBytes();
entries.add(new PackageEntry(
zipEntry.getName(),
content,
content.length,
determineContentType(zipEntry.getName())
));
}
zis.closeEntry();
}
}
return entries;
}
private String determineContentType(String filename) {
if (filename.endsWith(".py")) return "text/x-python";
if (filename.endsWith(".json")) return "application/json";
if (filename.endsWith(".yaml") || filename.endsWith(".yml")) return "application/x-yaml";
if (filename.endsWith(".txt")) return "text/plain";
if (filename.endsWith(".md")) return "text/markdown";
return "application/octet-stream";
}
} }

View file

@ -4,11 +4,11 @@ import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.token.ApiTokenService; import com.iflytek.skillhub.auth.token.ApiTokenService;
import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.MessageResponse;
import com.iflytek.skillhub.dto.TokenCreateRequest; import com.iflytek.skillhub.dto.TokenCreateRequest;
import com.iflytek.skillhub.dto.TokenCreateResponse; import com.iflytek.skillhub.dto.TokenCreateResponse;
import com.iflytek.skillhub.dto.TokenSummaryResponse; import com.iflytek.skillhub.dto.TokenSummaryResponse;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@ -59,10 +59,10 @@ public class TokenController extends BaseApiController {
} }
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
public ApiResponse<MessageResponse> revoke( public ResponseEntity<Void> revoke(
@AuthenticationPrincipal PlatformPrincipal principal, @AuthenticationPrincipal PlatformPrincipal principal,
@PathVariable Long id) { @PathVariable Long id) {
apiTokenService.revokeToken(id, principal.userId()); apiTokenService.revokeToken(id, principal.userId());
return ok("response.success.revoked", new MessageResponse("Token revoked")); return ResponseEntity.noContent().build();
} }
} }

View file

@ -5,7 +5,7 @@ import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.AuditLogItemResponse; import com.iflytek.skillhub.dto.AuditLogItemResponse;
import com.iflytek.skillhub.dto.PageResponse; import com.iflytek.skillhub.dto.PageResponse;
import com.iflytek.skillhub.domain.audit.AuditLogQueryService; import com.iflytek.skillhub.service.AdminAuditLogAppService;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@ -13,12 +13,12 @@ import org.springframework.web.bind.annotation.*;
@RequestMapping("/api/v1/admin/audit-logs") @RequestMapping("/api/v1/admin/audit-logs")
public class AuditLogController extends BaseApiController { public class AuditLogController extends BaseApiController {
private final AuditLogQueryService auditLogQueryService; private final AdminAuditLogAppService adminAuditLogAppService;
public AuditLogController(ApiResponseFactory responseFactory, public AuditLogController(AdminAuditLogAppService adminAuditLogAppService,
AuditLogQueryService auditLogQueryService) { ApiResponseFactory responseFactory) {
super(responseFactory); super(responseFactory);
this.auditLogQueryService = auditLogQueryService; this.adminAuditLogAppService = adminAuditLogAppService;
} }
@GetMapping @GetMapping
@ -28,16 +28,6 @@ public class AuditLogController extends BaseApiController {
@RequestParam(defaultValue = "20") int size, @RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) String userId, @RequestParam(required = false) String userId,
@RequestParam(required = false) String action) { @RequestParam(required = false) String action) {
var logs = auditLogQueryService.list(page, size, userId, action) return ok("response.success.read", adminAuditLogAppService.listAuditLogs(page, size, userId, action));
.map(log -> new AuditLogItemResponse(
String.valueOf(log.getId()),
log.getActorUserId(),
log.getAction(),
log.getTargetType(),
log.getTargetId() != null ? String.valueOf(log.getTargetId()) : "",
log.getCreatedAt(),
log.getClientIp()
));
return ok("response.success.read", PageResponse.from(logs));
} }
} }

View file

@ -1,7 +1,7 @@
package com.iflytek.skillhub.controller.admin; package com.iflytek.skillhub.controller.admin;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.controller.BaseApiController; import com.iflytek.skillhub.controller.BaseApiController;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.dto.AdminUserMutationResponse; import com.iflytek.skillhub.dto.AdminUserMutationResponse;
import com.iflytek.skillhub.dto.AdminUserRoleUpdateRequest; import com.iflytek.skillhub.dto.AdminUserRoleUpdateRequest;
import com.iflytek.skillhub.dto.AdminUserStatusUpdateRequest; import com.iflytek.skillhub.dto.AdminUserStatusUpdateRequest;
@ -9,7 +9,7 @@ import com.iflytek.skillhub.dto.AdminUserSummaryResponse;
import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.PageResponse; import com.iflytek.skillhub.dto.PageResponse;
import com.iflytek.skillhub.service.AdminUserManagementService; import com.iflytek.skillhub.service.AdminUserAppService;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
@ -19,12 +19,12 @@ import org.springframework.web.bind.annotation.*;
@RequestMapping("/api/v1/admin/users") @RequestMapping("/api/v1/admin/users")
public class UserManagementController extends BaseApiController { public class UserManagementController extends BaseApiController {
private final AdminUserManagementService adminUserManagementService; private final AdminUserAppService adminUserAppService;
public UserManagementController(ApiResponseFactory responseFactory, public UserManagementController(AdminUserAppService adminUserAppService,
AdminUserManagementService adminUserManagementService) { ApiResponseFactory responseFactory) {
super(responseFactory); super(responseFactory);
this.adminUserManagementService = adminUserManagementService; this.adminUserAppService = adminUserAppService;
} }
@GetMapping @GetMapping
@ -34,17 +34,17 @@ public class UserManagementController extends BaseApiController {
@RequestParam(required = false) String status, @RequestParam(required = false) String status,
@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) { @RequestParam(defaultValue = "20") int size) {
return ok("response.success.read", adminUserManagementService.listUsers(search, status, page, size)); return ok("response.success.read", adminUserAppService.listUsers(search, status, page, size));
} }
@PutMapping("/{userId}/role") @PutMapping("/{userId}/role")
@PreAuthorize("hasAnyRole('USER_ADMIN', 'SUPER_ADMIN')") @PreAuthorize("hasAnyRole('USER_ADMIN', 'SUPER_ADMIN')")
public ApiResponse<AdminUserMutationResponse> updateUserRole( public ApiResponse<AdminUserMutationResponse> updateUserRole(
@PathVariable String userId, @PathVariable String userId,
@Valid @RequestBody AdminUserRoleUpdateRequest request, @AuthenticationPrincipal PlatformPrincipal principal,
@AuthenticationPrincipal PlatformPrincipal principal) { @Valid @RequestBody AdminUserRoleUpdateRequest request) {
AdminUserSummaryResponse user = adminUserManagementService.updateUserRole(userId, request.role(), principal); return ok("response.success.updated",
return ok("response.success.updated", new AdminUserMutationResponse(user.userId(), request.role(), user.status())); adminUserAppService.updateUserRole(userId, request.role(), principal.platformRoles()));
} }
@PutMapping("/{userId}/status") @PutMapping("/{userId}/status")
@ -52,28 +52,24 @@ public class UserManagementController extends BaseApiController {
public ApiResponse<AdminUserMutationResponse> updateUserStatus( public ApiResponse<AdminUserMutationResponse> updateUserStatus(
@PathVariable String userId, @PathVariable String userId,
@Valid @RequestBody AdminUserStatusUpdateRequest request) { @Valid @RequestBody AdminUserStatusUpdateRequest request) {
AdminUserSummaryResponse user = adminUserManagementService.updateUserStatus(userId, request.status()); return ok("response.success.updated", adminUserAppService.updateUserStatus(userId, request.status()));
return ok("response.success.updated", new AdminUserMutationResponse(user.userId(), null, user.status()));
} }
@PostMapping("/{userId}/approve") @PostMapping("/{userId}/approve")
@PreAuthorize("hasAnyRole('USER_ADMIN', 'SUPER_ADMIN')") @PreAuthorize("hasAnyRole('USER_ADMIN', 'SUPER_ADMIN')")
public ApiResponse<AdminUserMutationResponse> approveUser(@PathVariable String userId) { public ApiResponse<AdminUserMutationResponse> approveUser(@PathVariable String userId) {
AdminUserSummaryResponse user = adminUserManagementService.approveUser(userId); return ok("response.success.updated", adminUserAppService.updateUserStatus(userId, "ACTIVE"));
return ok("response.success.updated", new AdminUserMutationResponse(user.userId(), null, user.status()));
} }
@PostMapping("/{userId}/disable") @PostMapping("/{userId}/disable")
@PreAuthorize("hasAnyRole('USER_ADMIN', 'SUPER_ADMIN')") @PreAuthorize("hasAnyRole('USER_ADMIN', 'SUPER_ADMIN')")
public ApiResponse<AdminUserMutationResponse> disableUser(@PathVariable String userId) { public ApiResponse<AdminUserMutationResponse> disableUser(@PathVariable String userId) {
AdminUserSummaryResponse user = adminUserManagementService.disableUser(userId); return ok("response.success.updated", adminUserAppService.updateUserStatus(userId, "DISABLED"));
return ok("response.success.updated", new AdminUserMutationResponse(user.userId(), null, user.status()));
} }
@PostMapping("/{userId}/enable") @PostMapping("/{userId}/enable")
@PreAuthorize("hasAnyRole('USER_ADMIN', 'SUPER_ADMIN')") @PreAuthorize("hasAnyRole('USER_ADMIN', 'SUPER_ADMIN')")
public ApiResponse<AdminUserMutationResponse> enableUser(@PathVariable String userId) { public ApiResponse<AdminUserMutationResponse> enableUser(@PathVariable String userId) {
AdminUserSummaryResponse user = adminUserManagementService.enableUser(userId); return ok("response.success.updated", adminUserAppService.updateUserStatus(userId, "ACTIVE"));
return ok("response.success.updated", new AdminUserMutationResponse(user.userId(), null, user.status()));
} }
} }

View file

@ -2,8 +2,9 @@ package com.iflytek.skillhub.controller.cli;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.controller.BaseApiController; import com.iflytek.skillhub.controller.BaseApiController;
import com.iflytek.skillhub.controller.support.ZipPackageExtractor; import com.iflytek.skillhub.controller.support.SkillPackageArchiveExtractor;
import com.iflytek.skillhub.domain.audit.AuditLogService; import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.skill.service.SkillPublishService; import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
import com.iflytek.skillhub.domain.skill.validation.PackageEntry; import com.iflytek.skillhub.domain.skill.validation.PackageEntry;
@ -26,18 +27,18 @@ import java.util.List;
public class CliPublishController extends BaseApiController { public class CliPublishController extends BaseApiController {
private final SkillPublishService skillPublishService; private final SkillPublishService skillPublishService;
private final ZipPackageExtractor zipPackageExtractor; private final SkillPackageArchiveExtractor skillPackageArchiveExtractor;
private final SkillHubMetrics skillHubMetrics; private final SkillHubMetrics skillHubMetrics;
private final AuditLogService auditLogService; private final AuditLogService auditLogService;
public CliPublishController(SkillPublishService skillPublishService, public CliPublishController(SkillPublishService skillPublishService,
ZipPackageExtractor zipPackageExtractor, SkillPackageArchiveExtractor skillPackageArchiveExtractor,
ApiResponseFactory responseFactory, ApiResponseFactory responseFactory,
SkillHubMetrics skillHubMetrics, SkillHubMetrics skillHubMetrics,
AuditLogService auditLogService) { AuditLogService auditLogService) {
super(responseFactory); super(responseFactory);
this.skillPublishService = skillPublishService; this.skillPublishService = skillPublishService;
this.zipPackageExtractor = zipPackageExtractor; this.skillPackageArchiveExtractor = skillPackageArchiveExtractor;
this.skillHubMetrics = skillHubMetrics; this.skillHubMetrics = skillHubMetrics;
this.auditLogService = auditLogService; this.auditLogService = auditLogService;
} }
@ -53,7 +54,12 @@ public class CliPublishController extends BaseApiController {
SkillVisibility skillVisibility = SkillVisibility.valueOf(visibility.toUpperCase()); SkillVisibility skillVisibility = SkillVisibility.valueOf(visibility.toUpperCase());
List<PackageEntry> entries = zipPackageExtractor.extract(file); List<PackageEntry> entries;
try {
entries = skillPackageArchiveExtractor.extract(file);
} catch (IllegalArgumentException e) {
throw new DomainBadRequestException("error.skill.publish.package.invalid", e.getMessage());
}
SkillPublishService.PublishResult publishResult = skillPublishService.publishFromEntries( SkillPublishService.PublishResult publishResult = skillPublishService.publishFromEntries(
namespace, namespace,

View file

@ -18,15 +18,27 @@ import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.dto.*; import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.PageResponse;
import com.iflytek.skillhub.dto.PromotionActionRequest;
import com.iflytek.skillhub.dto.PromotionRequestDto;
import com.iflytek.skillhub.dto.PromotionResponseDto;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.MDC;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import org.slf4j.MDC;
@RestController @RestController
@RequestMapping("/api/v1/promotions") @RequestMapping("/api/v1/promotions")
@ -62,56 +74,57 @@ public class PromotionController extends BaseApiController {
} }
@PostMapping @PostMapping
public ApiResponse<PromotionResponseDto> submitPromotion( public ApiResponse<PromotionResponseDto> submitPromotion(@RequestBody PromotionRequestDto request,
@RequestBody PromotionRequestDto request, @RequestAttribute("userId") String userId,
@RequestAttribute("userId") String userId, @RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles, HttpServletRequest httpRequest) {
HttpServletRequest httpRequest) {
PromotionRequest promotion = promotionService.submitPromotion( PromotionRequest promotion = promotionService.submitPromotion(
request.sourceSkillId(), request.sourceVersionId(), request.sourceSkillId(),
request.targetNamespaceId(), userId, request.sourceVersionId(),
request.targetNamespaceId(),
userId,
userNsRoles != null ? userNsRoles : Map.of(), userNsRoles != null ? userNsRoles : Map.of(),
rbacService.getUserRoleCodes(userId)); rbacService.getUserRoleCodes(userId)
recordAudit("PROMOTION_SUBMIT", userId, promotion.getId(), httpRequest, );
"{\"sourceSkillId\":" + request.sourceSkillId() + ",\"sourceVersionId\":" + request.sourceVersionId() + "}"); recordAudit(
"PROMOTION_SUBMIT",
userId,
promotion.getId(),
httpRequest,
"{\"sourceSkillId\":" + request.sourceSkillId() + ",\"sourceVersionId\":" + request.sourceVersionId() + "}"
);
return ok("response.success.created", toResponse(promotion)); return ok("response.success.created", toResponse(promotion));
} }
@PostMapping("/{id}/approve") @PostMapping("/{id}/approve")
public ApiResponse<PromotionResponseDto> approvePromotion( public ApiResponse<PromotionResponseDto> approvePromotion(@PathVariable Long id,
@PathVariable Long id, @RequestBody(required = false) PromotionActionRequest request,
@RequestBody(required = false) PromotionActionRequest request, @RequestAttribute("userId") String userId,
@RequestAttribute("userId") String userId, HttpServletRequest httpRequest) {
HttpServletRequest httpRequest) {
String comment = request != null ? request.comment() : null; String comment = request != null ? request.comment() : null;
Set<String> platformRoles = rbacService.getUserRoleCodes(userId); PromotionRequest promotion = promotionService.approvePromotion(id, userId, comment, rbacService.getUserRoleCodes(userId));
PromotionRequest promotion = promotionService.approvePromotion(id, userId, comment, platformRoles);
recordAudit("PROMOTION_APPROVE", userId, promotion.getId(), httpRequest, detailWithComment(comment)); recordAudit("PROMOTION_APPROVE", userId, promotion.getId(), httpRequest, detailWithComment(comment));
return ok("response.success.updated", toResponse(promotion)); return ok("response.success.updated", toResponse(promotion));
} }
@PostMapping("/{id}/reject") @PostMapping("/{id}/reject")
public ApiResponse<PromotionResponseDto> rejectPromotion( public ApiResponse<PromotionResponseDto> rejectPromotion(@PathVariable Long id,
@PathVariable Long id, @RequestBody(required = false) PromotionActionRequest request,
@RequestBody(required = false) PromotionActionRequest request, @RequestAttribute("userId") String userId,
@RequestAttribute("userId") String userId, HttpServletRequest httpRequest) {
HttpServletRequest httpRequest) {
String comment = request != null ? request.comment() : null; String comment = request != null ? request.comment() : null;
Set<String> platformRoles = rbacService.getUserRoleCodes(userId); PromotionRequest promotion = promotionService.rejectPromotion(id, userId, comment, rbacService.getUserRoleCodes(userId));
PromotionRequest promotion = promotionService.rejectPromotion(id, userId, comment, platformRoles);
recordAudit("PROMOTION_REJECT", userId, promotion.getId(), httpRequest, detailWithComment(comment)); recordAudit("PROMOTION_REJECT", userId, promotion.getId(), httpRequest, detailWithComment(comment));
return ok("response.success.updated", toResponse(promotion)); return ok("response.success.updated", toResponse(promotion));
} }
@GetMapping @GetMapping
public ApiResponse<PageResponse<PromotionResponseDto>> listPromotions( public ApiResponse<PageResponse<PromotionResponseDto>> listPromotions(@RequestParam(defaultValue = "PENDING") String status,
@RequestParam(defaultValue = "PENDING") String status, @RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "20") int size, @RequestAttribute("userId") String userId) {
@RequestAttribute("userId") String userId) {
Set<String> platformRoles = rbacService.getUserRoleCodes(userId); Set<String> platformRoles = rbacService.getUserRoleCodes(userId);
boolean hasAdminRole = platformRoles.contains("SKILL_ADMIN") || platformRoles.contains("SUPER_ADMIN"); if (!platformRoles.contains("SKILL_ADMIN") && !platformRoles.contains("SUPER_ADMIN")) {
if (!hasAdminRole) {
throw new DomainForbiddenException("promotion.no_permission"); throw new DomainForbiddenException("promotion.no_permission");
} }
ReviewTaskStatus reviewStatus = ReviewTaskStatus.valueOf(status.toUpperCase()); ReviewTaskStatus reviewStatus = ReviewTaskStatus.valueOf(status.toUpperCase());
@ -120,13 +133,11 @@ public class PromotionController extends BaseApiController {
} }
@GetMapping("/pending") @GetMapping("/pending")
public ApiResponse<PageResponse<PromotionResponseDto>> listPendingPromotions( public ApiResponse<PageResponse<PromotionResponseDto>> listPendingPromotions(@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "20") int size, @RequestAttribute("userId") String userId) {
@RequestAttribute("userId") String userId) {
Set<String> platformRoles = rbacService.getUserRoleCodes(userId); Set<String> platformRoles = rbacService.getUserRoleCodes(userId);
boolean hasAdminRole = platformRoles.contains("SKILL_ADMIN") || platformRoles.contains("SUPER_ADMIN"); if (!platformRoles.contains("SKILL_ADMIN") && !platformRoles.contains("SUPER_ADMIN")) {
if (!hasAdminRole) {
throw new DomainForbiddenException("promotion.no_permission"); throw new DomainForbiddenException("promotion.no_permission");
} }
Page<PromotionRequest> requests = promotionRequestRepository.findByStatus( Page<PromotionRequest> requests = promotionRequestRepository.findByStatus(
@ -145,40 +156,39 @@ public class PromotionController extends BaseApiController {
return ok("response.success.read", toResponse(promotion)); return ok("response.success.read", toResponse(promotion));
} }
private PromotionResponseDto toResponse(PromotionRequest req) { private PromotionResponseDto toResponse(PromotionRequest request) {
Skill sourceSkill = skillRepository.findById(req.getSourceSkillId()) Skill sourceSkill = skillRepository.findById(request.getSourceSkillId())
.orElseThrow(() -> new DomainNotFoundException("skill.not_found", req.getSourceSkillId())); .orElseThrow(() -> new DomainNotFoundException("skill.not_found", request.getSourceSkillId()));
SkillVersion sourceVersion = skillVersionRepository.findById(req.getSourceVersionId()) SkillVersion sourceVersion = skillVersionRepository.findById(request.getSourceVersionId())
.orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", req.getSourceVersionId())); .orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", request.getSourceVersionId()));
Namespace sourceNs = namespaceRepository.findById(sourceSkill.getNamespaceId()) Namespace sourceNamespace = namespaceRepository.findById(sourceSkill.getNamespaceId())
.orElseThrow(() -> new DomainNotFoundException("namespace.not_found", sourceSkill.getNamespaceId())); .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", sourceSkill.getNamespaceId()));
Namespace targetNs = namespaceRepository.findById(req.getTargetNamespaceId()) Namespace targetNamespace = namespaceRepository.findById(request.getTargetNamespaceId())
.orElseThrow(() -> new DomainNotFoundException("namespace.not_found", req.getTargetNamespaceId())); .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", request.getTargetNamespaceId()));
String submittedByName = userAccountRepository.findById(req.getSubmittedBy()) String submittedByName = userAccountRepository.findById(request.getSubmittedBy())
.map(UserAccount::getDisplayName).orElse(null); .map(UserAccount::getDisplayName)
.orElse(null);
String reviewedByName = req.getReviewedBy() != null String reviewedByName = request.getReviewedBy() != null
? userAccountRepository.findById(req.getReviewedBy()) ? userAccountRepository.findById(request.getReviewedBy()).map(UserAccount::getDisplayName).orElse(null)
.map(UserAccount::getDisplayName).orElse(null)
: null; : null;
return new PromotionResponseDto( return new PromotionResponseDto(
req.getId(), request.getId(),
req.getSourceSkillId(), request.getSourceSkillId(),
sourceNs.getSlug(), sourceNamespace.getSlug(),
sourceSkill.getSlug(), sourceSkill.getSlug(),
sourceVersion.getVersion(), sourceVersion.getVersion(),
targetNs.getSlug(), targetNamespace.getSlug(),
req.getTargetSkillId(), request.getTargetSkillId(),
req.getStatus().name(), request.getStatus().name(),
req.getSubmittedBy(), request.getSubmittedBy(),
submittedByName, submittedByName,
req.getReviewedBy(), request.getReviewedBy(),
reviewedByName, reviewedByName,
req.getReviewComment(), request.getReviewComment(),
req.getSubmittedAt(), request.getSubmittedAt(),
req.getReviewedAt() request.getReviewedAt()
); );
} }

View file

@ -18,16 +18,28 @@ import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.dto.*; import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.PageResponse;
import com.iflytek.skillhub.dto.ReviewActionRequest;
import com.iflytek.skillhub.dto.ReviewTaskRequest;
import com.iflytek.skillhub.dto.ReviewTaskResponse;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.MDC;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import org.slf4j.MDC;
@RestController @RestController
@RequestMapping("/api/v1/reviews") @RequestMapping("/api/v1/reviews")
@ -63,11 +75,10 @@ public class ReviewController extends BaseApiController {
} }
@PostMapping @PostMapping
public ApiResponse<ReviewTaskResponse> submitReview( public ApiResponse<ReviewTaskResponse> submitReview(@RequestBody ReviewTaskRequest request,
@RequestBody ReviewTaskRequest request, @RequestAttribute("userId") String userId,
@RequestAttribute("userId") String userId, @RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles, HttpServletRequest httpRequest) {
HttpServletRequest httpRequest) {
ReviewTask task = reviewService.submitReview( ReviewTask task = reviewService.submitReview(
request.skillVersionId(), request.skillVersionId(),
userId, userId,
@ -79,54 +90,59 @@ public class ReviewController extends BaseApiController {
} }
@PostMapping("/{id}/approve") @PostMapping("/{id}/approve")
public ApiResponse<ReviewTaskResponse> approveReview( public ApiResponse<ReviewTaskResponse> approveReview(@PathVariable Long id,
@PathVariable Long id, @RequestBody(required = false) ReviewActionRequest request,
@RequestBody(required = false) ReviewActionRequest request, @RequestAttribute("userId") String userId,
@RequestAttribute("userId") String userId, @RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles, HttpServletRequest httpRequest) {
HttpServletRequest httpRequest) {
String comment = request != null ? request.comment() : null; String comment = request != null ? request.comment() : null;
Set<String> platformRoles = rbacService.getUserRoleCodes(userId); ReviewTask task = reviewService.approveReview(
ReviewTask task = reviewService.approveReview(id, userId, comment, id,
userNsRoles != null ? userNsRoles : Map.of(), platformRoles); userId,
comment,
userNsRoles != null ? userNsRoles : Map.of(),
rbacService.getUserRoleCodes(userId)
);
recordAudit("REVIEW_APPROVE", userId, task.getId(), httpRequest, detailWithComment(comment)); recordAudit("REVIEW_APPROVE", userId, task.getId(), httpRequest, detailWithComment(comment));
return ok("response.success.updated", toResponse(task)); return ok("response.success.updated", toResponse(task));
} }
@PostMapping("/{id}/reject") @PostMapping("/{id}/reject")
public ApiResponse<ReviewTaskResponse> rejectReview( public ApiResponse<ReviewTaskResponse> rejectReview(@PathVariable Long id,
@PathVariable Long id, @RequestBody(required = false) ReviewActionRequest request,
@RequestBody(required = false) ReviewActionRequest request, @RequestAttribute("userId") String userId,
@RequestAttribute("userId") String userId, @RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles, HttpServletRequest httpRequest) {
HttpServletRequest httpRequest) {
String comment = request != null ? request.comment() : null; String comment = request != null ? request.comment() : null;
Set<String> platformRoles = rbacService.getUserRoleCodes(userId); ReviewTask task = reviewService.rejectReview(
ReviewTask task = reviewService.rejectReview(id, userId, comment, id,
userNsRoles != null ? userNsRoles : Map.of(), platformRoles); userId,
comment,
userNsRoles != null ? userNsRoles : Map.of(),
rbacService.getUserRoleCodes(userId)
);
recordAudit("REVIEW_REJECT", userId, task.getId(), httpRequest, detailWithComment(comment)); recordAudit("REVIEW_REJECT", userId, task.getId(), httpRequest, detailWithComment(comment));
return ok("response.success.updated", toResponse(task)); return ok("response.success.updated", toResponse(task));
} }
@PostMapping("/{id}/withdraw") @PostMapping("/{id}/withdraw")
public ApiResponse<Void> withdrawReview( public ApiResponse<Void> withdrawReview(@PathVariable Long id,
@PathVariable Long id, @RequestAttribute("userId") String userId,
@RequestAttribute("userId") String userId, HttpServletRequest httpRequest) {
HttpServletRequest httpRequest) { ReviewTask task = reviewTaskRepository.findById(id)
ReviewTask task = reviewTaskRepository.findById(id).orElseThrow(); .orElseThrow(() -> new DomainNotFoundException("review_task.not_found", id));
reviewService.withdrawReview(task.getSkillVersionId(), userId); reviewService.withdrawReview(task.getSkillVersionId(), userId);
recordAudit("REVIEW_WITHDRAW", userId, id, httpRequest, "{\"skillVersionId\":" + task.getSkillVersionId() + "}"); recordAudit("REVIEW_WITHDRAW", userId, id, httpRequest, "{\"skillVersionId\":" + task.getSkillVersionId() + "}");
return ok("response.success.updated", null); return ok("response.success.updated", null);
} }
@GetMapping @GetMapping
public ApiResponse<PageResponse<ReviewTaskResponse>> listReviews( public ApiResponse<PageResponse<ReviewTaskResponse>> listReviews(@RequestParam String status,
@RequestParam String status, @RequestParam(required = false) Long namespaceId,
@RequestParam(required = false) Long namespaceId, @RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "20") int size, @RequestAttribute("userId") String userId,
@RequestAttribute("userId") String userId, @RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
ReviewTaskStatus reviewStatus = ReviewTaskStatus.valueOf(status.toUpperCase()); ReviewTaskStatus reviewStatus = ReviewTaskStatus.valueOf(status.toUpperCase());
Map<Long, NamespaceRole> namespaceRoles = userNsRoles != null ? userNsRoles : Map.of(); Map<Long, NamespaceRole> namespaceRoles = userNsRoles != null ? userNsRoles : Map.of();
@ -135,7 +151,12 @@ public class ReviewController extends BaseApiController {
Namespace namespace = namespaceRepository.findById(namespaceId) Namespace namespace = namespaceRepository.findById(namespaceId)
.orElseThrow(() -> new DomainNotFoundException("namespace.not_found", namespaceId)); .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", namespaceId));
ReviewTask probe = new ReviewTask(0L, namespaceId, userId); ReviewTask probe = new ReviewTask(0L, namespaceId, userId);
if (!reviewService.canReviewNamespace(probe, userId, namespace.getType(), namespaceRoles, rbacService.getUserRoleCodes(userId))) { if (!reviewService.canReviewNamespace(
probe,
userId,
namespace.getType(),
namespaceRoles,
rbacService.getUserRoleCodes(userId))) {
throw new DomainForbiddenException("review.no_permission"); throw new DomainForbiddenException("review.no_permission");
} }
tasks = reviewTaskRepository.findByNamespaceIdAndStatus(namespaceId, reviewStatus, PageRequest.of(page, size)); tasks = reviewTaskRepository.findByNamespaceIdAndStatus(namespaceId, reviewStatus, PageRequest.of(page, size));
@ -147,34 +168,40 @@ public class ReviewController extends BaseApiController {
.filter(task -> canViewReview(task, userId, namespaceRoles)) .filter(task -> canViewReview(task, userId, namespaceRoles))
.map(this::toResponse) .map(this::toResponse)
.toList(); .toList();
Page<ReviewTaskResponse> responsePage = new PageImpl<>(visibleItems, tasks.getPageable(), visibleItems.size());
return ok("response.success.read", PageResponse.from(responsePage)); return ok(
"response.success.read",
PageResponse.from(new PageImpl<>(visibleItems, tasks.getPageable(), visibleItems.size()))
);
} }
@GetMapping("/pending") @GetMapping("/pending")
public ApiResponse<PageResponse<ReviewTaskResponse>> listPendingReviews( public ApiResponse<PageResponse<ReviewTaskResponse>> listPendingReviews(@RequestParam Long namespaceId,
@RequestParam Long namespaceId, @RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "20") int size, @RequestAttribute("userId") String userId,
@RequestAttribute("userId") String userId, @RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
Namespace namespace = namespaceRepository.findById(namespaceId) Namespace namespace = namespaceRepository.findById(namespaceId)
.orElseThrow(() -> new DomainNotFoundException("namespace.not_found", namespaceId)); .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", namespaceId));
ReviewTask probe = new ReviewTask(0L, namespaceId, "probe"); ReviewTask probe = new ReviewTask(0L, namespaceId, userId);
if (!reviewService.canReviewNamespace(probe, userId, namespace.getType(), if (!reviewService.canReviewNamespace(
userNsRoles != null ? userNsRoles : Map.of(), rbacService.getUserRoleCodes(userId))) { probe,
userId,
namespace.getType(),
userNsRoles != null ? userNsRoles : Map.of(),
rbacService.getUserRoleCodes(userId))) {
throw new DomainForbiddenException("review.no_permission"); throw new DomainForbiddenException("review.no_permission");
} }
Page<ReviewTask> tasks = reviewTaskRepository.findByNamespaceIdAndStatus( Page<ReviewTask> tasks = reviewTaskRepository.findByNamespaceIdAndStatus(
namespaceId, ReviewTaskStatus.PENDING, PageRequest.of(page, size)); namespaceId, ReviewTaskStatus.PENDING, PageRequest.of(page, size));
return ok("response.success.read", PageResponse.from(tasks.map(this::toResponse))); return ok("response.success.read", PageResponse.from(tasks.map(this::toResponse)));
} }
@GetMapping("/my-submissions") @GetMapping("/my-submissions")
public ApiResponse<PageResponse<ReviewTaskResponse>> listMySubmissions( public ApiResponse<PageResponse<ReviewTaskResponse>> listMySubmissions(@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "20") int size, @RequestAttribute("userId") String userId) {
@RequestAttribute("userId") String userId) {
Page<ReviewTask> tasks = reviewTaskRepository.findBySubmittedByAndStatus( Page<ReviewTask> tasks = reviewTaskRepository.findBySubmittedByAndStatus(
userId, ReviewTaskStatus.PENDING, PageRequest.of(page, size)); userId, ReviewTaskStatus.PENDING, PageRequest.of(page, size));
return ok("response.success.read", PageResponse.from(tasks.map(this::toResponse))); return ok("response.success.read", PageResponse.from(tasks.map(this::toResponse)));
@ -188,35 +215,38 @@ public class ReviewController extends BaseApiController {
.orElseThrow(() -> new DomainNotFoundException("review_task.not_found", id)); .orElseThrow(() -> new DomainNotFoundException("review_task.not_found", id));
Namespace namespace = namespaceRepository.findById(task.getNamespaceId()) Namespace namespace = namespaceRepository.findById(task.getNamespaceId())
.orElseThrow(() -> new DomainNotFoundException("namespace.not_found", task.getNamespaceId())); .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", task.getNamespaceId()));
if (!reviewService.canViewReview(task, userId, namespace.getType(), if (!reviewService.canViewReview(
userNsRoles != null ? userNsRoles : Map.of(), rbacService.getUserRoleCodes(userId))) { task,
userId,
namespace.getType(),
userNsRoles != null ? userNsRoles : Map.of(),
rbacService.getUserRoleCodes(userId))) {
throw new DomainForbiddenException("review.no_permission"); throw new DomainForbiddenException("review.no_permission");
} }
return ok("response.success.read", toResponse(task)); return ok("response.success.read", toResponse(task));
} }
private ReviewTaskResponse toResponse(ReviewTask task) { private ReviewTaskResponse toResponse(ReviewTask task) {
SkillVersion sv = skillVersionRepository.findById(task.getSkillVersionId()) SkillVersion skillVersion = skillVersionRepository.findById(task.getSkillVersionId())
.orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", task.getSkillVersionId())); .orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", task.getSkillVersionId()));
Skill skill = skillRepository.findById(sv.getSkillId()) Skill skill = skillRepository.findById(skillVersion.getSkillId())
.orElseThrow(() -> new DomainNotFoundException("skill.not_found", sv.getSkillId())); .orElseThrow(() -> new DomainNotFoundException("skill.not_found", skillVersion.getSkillId()));
Namespace ns = namespaceRepository.findById(skill.getNamespaceId()) Namespace namespace = namespaceRepository.findById(skill.getNamespaceId())
.orElseThrow(() -> new DomainNotFoundException("namespace.not_found", skill.getNamespaceId())); .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", skill.getNamespaceId()));
String submittedByName = userAccountRepository.findById(task.getSubmittedBy()) String submittedByName = userAccountRepository.findById(task.getSubmittedBy())
.map(UserAccount::getDisplayName).orElse(null); .map(UserAccount::getDisplayName)
.orElse(null);
String reviewedByName = task.getReviewedBy() != null String reviewedByName = task.getReviewedBy() != null
? userAccountRepository.findById(task.getReviewedBy()) ? userAccountRepository.findById(task.getReviewedBy()).map(UserAccount::getDisplayName).orElse(null)
.map(UserAccount::getDisplayName).orElse(null)
: null; : null;
return new ReviewTaskResponse( return new ReviewTaskResponse(
task.getId(), task.getId(),
task.getSkillVersionId(), task.getSkillVersionId(),
ns.getSlug(), namespace.getSlug(),
skill.getSlug(), skill.getSlug(),
sv.getVersion(), skillVersion.getVersion(),
task.getStatus().name(), task.getStatus().name(),
task.getSubmittedBy(), task.getSubmittedBy(),
submittedByName, submittedByName,
@ -231,7 +261,13 @@ public class ReviewController extends BaseApiController {
private boolean canViewReview(ReviewTask task, String userId, Map<Long, NamespaceRole> namespaceRoles) { private boolean canViewReview(ReviewTask task, String userId, Map<Long, NamespaceRole> namespaceRoles) {
Namespace namespace = namespaceRepository.findById(task.getNamespaceId()) Namespace namespace = namespaceRepository.findById(task.getNamespaceId())
.orElseThrow(() -> new DomainNotFoundException("namespace.not_found", task.getNamespaceId())); .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", task.getNamespaceId()));
return reviewService.canViewReview(task, userId, namespace.getType(), namespaceRoles, rbacService.getUserRoleCodes(userId)); return reviewService.canViewReview(
task,
userId,
namespace.getType(),
namespaceRoles,
rbacService.getUserRoleCodes(userId)
);
} }
private void recordAudit(String action, private void recordAudit(String action,

View file

@ -2,7 +2,8 @@ package com.iflytek.skillhub.controller.portal;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.controller.BaseApiController; import com.iflytek.skillhub.controller.BaseApiController;
import com.iflytek.skillhub.controller.support.ZipPackageExtractor; import com.iflytek.skillhub.controller.support.SkillPackageArchiveExtractor;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.skill.service.SkillPublishService; import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
import com.iflytek.skillhub.domain.skill.validation.PackageEntry; import com.iflytek.skillhub.domain.skill.validation.PackageEntry;
@ -23,16 +24,16 @@ import java.util.List;
public class SkillPublishController extends BaseApiController { public class SkillPublishController extends BaseApiController {
private final SkillPublishService skillPublishService; private final SkillPublishService skillPublishService;
private final ZipPackageExtractor zipPackageExtractor; private final SkillPackageArchiveExtractor skillPackageArchiveExtractor;
private final SkillHubMetrics skillHubMetrics; private final SkillHubMetrics skillHubMetrics;
public SkillPublishController(SkillPublishService skillPublishService, public SkillPublishController(SkillPublishService skillPublishService,
ZipPackageExtractor zipPackageExtractor, SkillPackageArchiveExtractor skillPackageArchiveExtractor,
ApiResponseFactory responseFactory, ApiResponseFactory responseFactory,
SkillHubMetrics skillHubMetrics) { SkillHubMetrics skillHubMetrics) {
super(responseFactory); super(responseFactory);
this.skillPublishService = skillPublishService; this.skillPublishService = skillPublishService;
this.zipPackageExtractor = zipPackageExtractor; this.skillPackageArchiveExtractor = skillPackageArchiveExtractor;
this.skillHubMetrics = skillHubMetrics; this.skillHubMetrics = skillHubMetrics;
} }
@ -46,7 +47,12 @@ public class SkillPublishController extends BaseApiController {
SkillVisibility skillVisibility = SkillVisibility.valueOf(visibility.toUpperCase()); SkillVisibility skillVisibility = SkillVisibility.valueOf(visibility.toUpperCase());
List<PackageEntry> entries = zipPackageExtractor.extract(file); List<PackageEntry> entries;
try {
entries = skillPackageArchiveExtractor.extract(file);
} catch (IllegalArgumentException e) {
throw new DomainBadRequestException("error.skill.publish.package.invalid", e.getMessage());
}
SkillPublishService.PublishResult publishResult = skillPublishService.publishFromEntries( SkillPublishService.PublishResult publishResult = skillPublishService.publishFromEntries(
namespace, namespace,

View file

@ -0,0 +1,92 @@
package com.iflytek.skillhub.controller.support;
import com.iflytek.skillhub.domain.skill.validation.PackageEntry;
import com.iflytek.skillhub.domain.skill.validation.SkillPackagePolicy;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
@Component
public class SkillPackageArchiveExtractor {
public List<PackageEntry> extract(MultipartFile file) throws IOException {
if (file.getSize() > SkillPackagePolicy.MAX_TOTAL_PACKAGE_SIZE) {
throw new IllegalArgumentException(
"Package too large: " + file.getSize() + " bytes (max: "
+ SkillPackagePolicy.MAX_TOTAL_PACKAGE_SIZE + ")"
);
}
List<PackageEntry> entries = new ArrayList<>();
long totalSize = 0;
try (ZipInputStream zis = new ZipInputStream(file.getInputStream())) {
ZipEntry zipEntry;
while ((zipEntry = zis.getNextEntry()) != null) {
if (zipEntry.isDirectory()) {
zis.closeEntry();
continue;
}
if (entries.size() >= SkillPackagePolicy.MAX_FILE_COUNT) {
throw new IllegalArgumentException(
"Too many files: more than " + SkillPackagePolicy.MAX_FILE_COUNT
);
}
String normalizedPath = SkillPackagePolicy.normalizeEntryPath(zipEntry.getName());
byte[] content = readEntry(zis, normalizedPath);
totalSize += content.length;
if (totalSize > SkillPackagePolicy.MAX_TOTAL_PACKAGE_SIZE) {
throw new IllegalArgumentException(
"Package too large: " + totalSize + " bytes (max: "
+ SkillPackagePolicy.MAX_TOTAL_PACKAGE_SIZE + ")"
);
}
entries.add(new PackageEntry(
normalizedPath,
content,
content.length,
determineContentType(normalizedPath)
));
zis.closeEntry();
}
}
return entries;
}
private byte[] readEntry(ZipInputStream zis, String path) throws IOException {
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
byte[] buffer = new byte[8192];
long totalRead = 0;
int read;
while ((read = zis.read(buffer)) != -1) {
totalRead += read;
if (totalRead > SkillPackagePolicy.MAX_SINGLE_FILE_SIZE) {
throw new IllegalArgumentException(
"File too large: " + path + " (" + totalRead + " bytes, max: "
+ SkillPackagePolicy.MAX_SINGLE_FILE_SIZE + ")"
);
}
outputStream.write(buffer, 0, read);
}
return outputStream.toByteArray();
}
private String determineContentType(String filename) {
if (filename.endsWith(".py")) return "text/x-python";
if (filename.endsWith(".json")) return "application/json";
if (filename.endsWith(".yaml") || filename.endsWith(".yml")) return "application/x-yaml";
if (filename.endsWith(".txt")) return "text/plain";
if (filename.endsWith(".md")) return "text/markdown";
return "application/octet-stream";
}
}

View file

@ -4,11 +4,11 @@ import java.time.LocalDateTime;
import java.util.List; import java.util.List;
public record AdminUserSummaryResponse( public record AdminUserSummaryResponse(
String userId, String id,
String username, String username,
String email, String email,
List<String> platformRoles,
String status, String status,
List<String> platformRoles,
LocalDateTime createdAt LocalDateTime createdAt
) { ) {
} }

View file

@ -3,12 +3,12 @@ package com.iflytek.skillhub.dto;
import java.time.Instant; import java.time.Instant;
public record AuditLogItemResponse( public record AuditLogItemResponse(
String id, Long id,
String userId,
String action, String action,
String resourceType, String userId,
String resourceId, String username,
Instant timestamp, String details,
String ipAddress String ipAddress,
Instant timestamp
) { ) {
} }

View file

@ -0,0 +1,74 @@
package com.iflytek.skillhub.repository;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserStatus;
import jakarta.persistence.EntityManager;
import jakarta.persistence.TypedQuery;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
@Repository
public class AdminUserSearchRepository {
private final EntityManager entityManager;
public AdminUserSearchRepository(EntityManager entityManager) {
this.entityManager = entityManager;
}
public Page<UserAccount> search(String search, UserStatus status, Pageable pageable) {
CriteriaBuilder builder = entityManager.getCriteriaBuilder();
CriteriaQuery<UserAccount> query = builder.createQuery(UserAccount.class);
Root<UserAccount> root = query.from(UserAccount.class);
List<Predicate> predicates = buildPredicates(search, status, builder, root);
query.select(root)
.where(predicates.toArray(Predicate[]::new))
.orderBy(builder.desc(root.get("createdAt")));
TypedQuery<UserAccount> typedQuery = entityManager.createQuery(query);
typedQuery.setFirstResult((int) pageable.getOffset());
typedQuery.setMaxResults(pageable.getPageSize());
List<UserAccount> users = typedQuery.getResultList();
CriteriaQuery<Long> countQuery = builder.createQuery(Long.class);
Root<UserAccount> countRoot = countQuery.from(UserAccount.class);
List<Predicate> countPredicates = buildPredicates(search, status, builder, countRoot);
countQuery.select(builder.count(countRoot))
.where(countPredicates.toArray(Predicate[]::new));
long total = entityManager.createQuery(countQuery).getSingleResult();
return new PageImpl<>(users, pageable, total);
}
private List<Predicate> buildPredicates(
String search,
UserStatus status,
CriteriaBuilder builder,
Root<UserAccount> root) {
List<Predicate> predicates = new ArrayList<>();
if (StringUtils.hasText(search)) {
String normalized = "%" + search.trim().toLowerCase(Locale.ROOT) + "%";
predicates.add(builder.or(
builder.like(builder.lower(root.get("id")), normalized),
builder.like(builder.lower(root.get("displayName")), normalized),
builder.like(builder.lower(root.get("email")), normalized)
));
}
if (status != null) {
predicates.add(builder.equal(root.get("status"), status));
}
return predicates;
}
}

View file

@ -0,0 +1,97 @@
package com.iflytek.skillhub.service;
import com.iflytek.skillhub.dto.AuditLogItemResponse;
import com.iflytek.skillhub.dto.PageResponse;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.List;
@Service
public class AdminAuditLogAppService {
private final NamedParameterJdbcTemplate namedParameterJdbcTemplate;
public AdminAuditLogAppService(NamedParameterJdbcTemplate namedParameterJdbcTemplate) {
this.namedParameterJdbcTemplate = namedParameterJdbcTemplate;
}
@Transactional(readOnly = true)
public PageResponse<AuditLogItemResponse> listAuditLogs(int page, int size, String userId, String action) {
MapSqlParameterSource parameters = new MapSqlParameterSource()
.addValue("limit", size)
.addValue("offset", Math.max(page, 0) * size);
String whereClause = buildWhereClause(parameters, userId, action);
Long total = namedParameterJdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM audit_log al" + whereClause,
parameters,
Long.class
);
List<AuditLogItemResponse> items = namedParameterJdbcTemplate.query(
"""
SELECT al.id,
al.action,
al.actor_user_id,
ua.display_name,
al.detail_json,
al.target_type,
al.target_id,
al.client_ip,
al.created_at
FROM audit_log al
LEFT JOIN user_account ua ON ua.id = al.actor_user_id
""" + whereClause + """
ORDER BY al.created_at DESC
LIMIT :limit OFFSET :offset
""",
parameters,
(rs, rowNum) -> new AuditLogItemResponse(
rs.getLong("id"),
rs.getString("action"),
rs.getString("actor_user_id"),
rs.getString("display_name"),
renderDetails(
rs.getString("detail_json"),
rs.getString("target_type"),
rs.getObject("target_id")),
rs.getString("client_ip"),
toInstant(rs.getTimestamp("created_at")))
);
return new PageResponse<>(items, total == null ? 0 : total, page, size);
}
private String buildWhereClause(MapSqlParameterSource parameters, String userId, String action) {
StringBuilder clause = new StringBuilder(" WHERE 1 = 1");
if (StringUtils.hasText(userId)) {
clause.append(" AND al.actor_user_id = :userId");
parameters.addValue("userId", userId.trim());
}
if (StringUtils.hasText(action)) {
clause.append(" AND al.action = :action");
parameters.addValue("action", action.trim());
}
return clause.toString();
}
private String renderDetails(String detailJson, String targetType, Object targetId) {
if (StringUtils.hasText(detailJson)) {
return detailJson;
}
if (!StringUtils.hasText(targetType) && targetId == null) {
return null;
}
return targetType + ":" + targetId;
}
private Instant toInstant(Timestamp timestamp) {
return timestamp == null ? null : timestamp.toInstant();
}
}

View file

@ -0,0 +1,145 @@
package com.iflytek.skillhub.service;
import com.iflytek.skillhub.auth.entity.Role;
import com.iflytek.skillhub.auth.entity.UserRoleBinding;
import com.iflytek.skillhub.auth.repository.RoleRepository;
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.domain.user.UserStatus;
import com.iflytek.skillhub.dto.AdminUserMutationResponse;
import com.iflytek.skillhub.dto.AdminUserSummaryResponse;
import com.iflytek.skillhub.dto.PageResponse;
import com.iflytek.skillhub.repository.AdminUserSearchRepository;
import org.springframework.data.domain.Page;
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;
import org.springframework.util.StringUtils;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
@Service
public class AdminUserAppService {
private static final Set<UserStatus> MANAGEABLE_STATUSES = Set.of(UserStatus.ACTIVE, UserStatus.DISABLED);
private final AdminUserSearchRepository adminUserSearchRepository;
private final UserAccountRepository userAccountRepository;
private final UserRoleBindingRepository userRoleBindingRepository;
private final RoleRepository roleRepository;
public AdminUserAppService(
AdminUserSearchRepository adminUserSearchRepository,
UserAccountRepository userAccountRepository,
UserRoleBindingRepository userRoleBindingRepository,
RoleRepository roleRepository) {
this.adminUserSearchRepository = adminUserSearchRepository;
this.userAccountRepository = userAccountRepository;
this.userRoleBindingRepository = userRoleBindingRepository;
this.roleRepository = roleRepository;
}
@Transactional(readOnly = true)
public PageResponse<AdminUserSummaryResponse> listUsers(String search, String status, int page, int size) {
Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"));
Page<UserAccount> result = adminUserSearchRepository.search(
search,
StringUtils.hasText(status) ? parseStatus(status) : null,
pageable
);
Map<String, List<String>> rolesByUserId = loadRolesByUserId(
result.getContent().stream().map(UserAccount::getId).toList());
List<AdminUserSummaryResponse> items = result.getContent().stream()
.map(user -> new AdminUserSummaryResponse(
user.getId(),
user.getDisplayName(),
user.getEmail(),
user.getStatus().name(),
rolesByUserId.getOrDefault(user.getId(), List.of()),
user.getCreatedAt()))
.toList();
return new PageResponse<>(items, result.getTotalElements(), result.getNumber(), result.getSize());
}
@Transactional
public AdminUserMutationResponse updateUserRole(String userId, String roleCode, Set<String> actorPlatformRoles) {
UserAccount user = loadUser(userId);
String normalizedRoleCode = normalizeRoleCode(roleCode);
if ("SUPER_ADMIN".equals(normalizedRoleCode)
&& (actorPlatformRoles == null || !actorPlatformRoles.contains("SUPER_ADMIN"))) {
throw new DomainForbiddenException("error.admin.user.role.superAdmin.assignDenied");
}
userRoleBindingRepository.deleteByUserId(user.getId());
if (!"USER".equals(normalizedRoleCode)) {
Role role = roleRepository.findByCode(normalizedRoleCode)
.orElseThrow(() -> new DomainBadRequestException("error.admin.user.role.invalid", roleCode));
userRoleBindingRepository.save(new UserRoleBinding(user.getId(), role));
}
return new AdminUserMutationResponse(user.getId(), normalizedRoleCode, user.getStatus().name());
}
@Transactional
public AdminUserMutationResponse updateUserStatus(String userId, String status) {
UserAccount user = loadUser(userId);
UserStatus nextStatus = parseManageableStatus(status);
user.setStatus(nextStatus);
userAccountRepository.save(user);
return new AdminUserMutationResponse(user.getId(), null, nextStatus.name());
}
private UserStatus parseManageableStatus(String status) {
UserStatus parsedStatus = parseStatus(status);
if (!MANAGEABLE_STATUSES.contains(parsedStatus)) {
throw new DomainBadRequestException("error.admin.user.status.unsupported");
}
return parsedStatus;
}
private UserStatus parseStatus(String status) {
try {
return UserStatus.valueOf(status.trim().toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException ex) {
throw new DomainBadRequestException("error.admin.user.status.invalid", status);
}
}
private String normalizeRoleCode(String roleCode) {
if (!StringUtils.hasText(roleCode)) {
throw new DomainBadRequestException("error.admin.user.role.invalid", roleCode);
}
return roleCode.trim().toUpperCase(Locale.ROOT);
}
private Map<String, List<String>> loadRolesByUserId(List<String> userIds) {
if (userIds.isEmpty()) {
return Map.of();
}
return userRoleBindingRepository.findByUserIdIn(userIds).stream()
.collect(Collectors.groupingBy(
UserRoleBinding::getUserId,
Collectors.mapping(binding -> binding.getRole().getCode(),
Collectors.collectingAndThen(Collectors.toList(),
roles -> roles.stream().sorted().toList()))));
}
private UserAccount loadUser(String userId) {
return userAccountRepository.findById(userId)
.orElseThrow(() -> new DomainNotFoundException("error.admin.user.notFound", userId));
}
}

View file

@ -110,8 +110,8 @@ public class AdminUserManagementService {
user.getId(), user.getId(),
user.getDisplayName(), user.getDisplayName(),
user.getEmail(), user.getEmail(),
List.copyOf(roles),
user.getStatus().name(), user.getStatus().name(),
List.copyOf(roles),
user.getCreatedAt() user.getCreatedAt()
); );
} }

View file

@ -56,12 +56,6 @@ error.skill.publish.package.invalid=Package validation failed: {0}
error.skill.publish.skillMd.notFound=SKILL.md not found error.skill.publish.skillMd.notFound=SKILL.md not found
error.skill.publish.precheck.failed=Pre-publish validation failed: {0} error.skill.publish.precheck.failed=Pre-publish validation failed: {0}
error.skill.notFound=Skill not found: {0} error.skill.notFound=Skill not found: {0}
skill.not_found=Skill not found: {0}
skill_version.not_found=Skill version not found: {0}
namespace.not_found=Namespace not found: {0}
promotion.not_found=Promotion request not found: {0}
review_task.not_found=Review task not found: {0}
review_task.not_found_for_version=Review task not found for skill version: {0}
error.skill.access.denied=Access denied to skill: {0} error.skill.access.denied=Access denied to skill: {0}
error.skill.status.notActive=Skill is not active error.skill.status.notActive=Skill is not active
error.skill.version.exists=Version already exists: {0} error.skill.version.exists=Version already exists: {0}
@ -82,33 +76,8 @@ error.deviceAuth.userCode.invalid=Invalid or expired user code
error.deviceAuth.deviceCode.expired=Device code expired error.deviceAuth.deviceCode.expired=Device code expired
error.deviceAuth.deviceCode.invalid=Device code expired or invalid error.deviceAuth.deviceCode.invalid=Device code expired or invalid
error.deviceAuth.deviceCode.used=Device code has already been used error.deviceAuth.deviceCode.used=Device code has already been used
error.auth.local.username.invalid=Username must be 3-64 characters and contain only letters, numbers, or underscores error.admin.user.notFound=User not found: {0}
error.auth.local.username.exists=Username already exists error.admin.user.role.invalid=Invalid role: {0}
error.auth.local.email.exists=Email already exists error.admin.user.role.superAdmin.assignDenied=Only SUPER_ADMIN can assign SUPER_ADMIN role
error.auth.local.invalidCredentials=Invalid username or password error.admin.user.status.invalid=Invalid user status: {0}
error.auth.local.accountDisabled=Account has been disabled error.admin.user.status.unsupported=Only ACTIVE or DISABLED status can be managed here
error.auth.local.accountPending=Account is pending approval
error.auth.local.accountMerged=Account has been merged into another account
error.auth.local.locked=Account is locked. Try again in {0} minute(s)
error.auth.local.notEnabled=Password login is not enabled for this account
error.auth.local.password.tooShort=Password must be at least 8 characters
error.auth.local.password.tooLong=Password must not exceed 128 characters
error.auth.local.password.tooWeak=Password must contain at least three character types
error.auth.merge.identifierRequired=Secondary account identifier is required
error.auth.merge.identifierInvalid=Secondary account identifier is invalid
error.auth.merge.primaryNotFound=Primary account not found
error.auth.merge.primaryNotActive=Primary account must be active
error.auth.merge.secondaryNotFound=Secondary account not found
error.auth.merge.secondaryNotActive=Secondary account must be active
error.auth.merge.sameAccount=Cannot merge the current account into itself
error.auth.merge.pendingExists=A pending merge request already exists for this secondary account
error.auth.merge.localCredentialConflict=Both accounts already have local credentials
error.auth.merge.requestNotFound=Merge request not found
error.auth.merge.requestNotPending=Merge request is not pending
error.auth.merge.requestNotVerified=Merge request is not verified
error.auth.merge.tokenExpired=Merge verification token has expired
error.auth.merge.invalidToken=Invalid merge verification token
error.admin.role.assign_super_admin_forbidden=Only SUPER_ADMIN can assign the SUPER_ADMIN role
error.role.notFound=Role not found: {0}
error.user.notFound=User not found: {0}
error.user.status.invalid=Invalid user status: {0}

View file

@ -51,12 +51,6 @@ error.skill.publish.package.invalid=技能包校验失败:{0}
error.skill.publish.skillMd.notFound=未找到 SKILL.md error.skill.publish.skillMd.notFound=未找到 SKILL.md
error.skill.publish.precheck.failed=预发布校验失败:{0} error.skill.publish.precheck.failed=预发布校验失败:{0}
error.skill.notFound=未找到技能:{0} error.skill.notFound=未找到技能:{0}
skill.not_found=未找到技能:{0}
skill_version.not_found=未找到技能版本:{0}
namespace.not_found=未找到命名空间:{0}
promotion.not_found=未找到推广申请:{0}
review_task.not_found=未找到审核任务:{0}
review_task.not_found_for_version=未找到该技能版本对应的审核任务:{0}
error.skill.access.denied=没有权限访问技能:{0} error.skill.access.denied=没有权限访问技能:{0}
error.skill.status.notActive=技能未处于 ACTIVE 状态 error.skill.status.notActive=技能未处于 ACTIVE 状态
error.skill.version.exists=版本已存在:{0} error.skill.version.exists=版本已存在:{0}
@ -77,33 +71,8 @@ error.deviceAuth.userCode.invalid=无效或已过期的用户验证码
error.deviceAuth.deviceCode.expired=设备验证码已过期 error.deviceAuth.deviceCode.expired=设备验证码已过期
error.deviceAuth.deviceCode.invalid=设备验证码无效或已过期 error.deviceAuth.deviceCode.invalid=设备验证码无效或已过期
error.deviceAuth.deviceCode.used=设备验证码已被使用 error.deviceAuth.deviceCode.used=设备验证码已被使用
error.auth.local.username.invalid=用户名长度必须为 3 到 64 个字符,且只能包含字母、数字或下划线 error.admin.user.notFound=鐢ㄦ埛涓嶅瓨鍦細{0}
error.auth.local.username.exists=用户名已存在 error.admin.user.role.invalid=鏃犳晥鐨勮鑹诧細{0}
error.auth.local.email.exists=邮箱已存在 error.admin.user.role.superAdmin.assignDenied=鍙湁 SUPER_ADMIN 鍙互鍒嗛厤 SUPER_ADMIN 瑙掕壊
error.auth.local.invalidCredentials=用户名或密码错误 error.admin.user.status.invalid=鏃犳晥鐨勭敤鎴风姸鎬侊細{0}
error.auth.local.accountDisabled=账号已被禁用 error.admin.user.status.unsupported=杩欓噷鍙厑璁告寜 ACTIVE 鎴?DISABLED 绠$悊鐢ㄦ埛鐘舵€?
error.auth.local.accountPending=账号仍在审核中
error.auth.local.accountMerged=账号已合并到其他账号
error.auth.local.locked=账号已锁定,请 {0} 分钟后重试
error.auth.local.notEnabled=当前账号未启用密码登录
error.auth.local.password.tooShort=密码长度至少为 8 位
error.auth.local.password.tooLong=密码长度不能超过 128 位
error.auth.local.password.tooWeak=密码至少需要包含三种字符类型
error.auth.merge.identifierRequired=待合并账号标识不能为空
error.auth.merge.identifierInvalid=待合并账号标识格式不正确
error.auth.merge.primaryNotFound=未找到主账号
error.auth.merge.primaryNotActive=主账号必须处于激活状态
error.auth.merge.secondaryNotFound=未找到待合并账号
error.auth.merge.secondaryNotActive=待合并账号必须处于激活状态
error.auth.merge.sameAccount=不能将当前账号合并到自己
error.auth.merge.pendingExists=该待合并账号已有进行中的合并请求
error.auth.merge.localCredentialConflict=两个账号都已启用本地密码登录,无法自动合并
error.auth.merge.requestNotFound=未找到合并请求
error.auth.merge.requestNotPending=该合并请求不处于待验证状态
error.auth.merge.requestNotVerified=该合并请求尚未完成验证
error.auth.merge.tokenExpired=合并验证 token 已过期
error.auth.merge.invalidToken=合并验证 token 无效
error.admin.role.assign_super_admin_forbidden=只有 SUPER_ADMIN 才能分配 SUPER_ADMIN 角色
error.role.notFound=角色不存在:{0}
error.user.notFound=用户不存在:{0}
error.user.status.invalid=非法的用户状态:{0}

View file

@ -3,40 +3,28 @@ package com.iflytek.skillhub.compat;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.auth.device.DeviceAuthService; import com.iflytek.skillhub.auth.device.DeviceAuthService;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
import com.iflytek.skillhub.domain.skill.service.SkillQueryService; import com.iflytek.skillhub.domain.skill.service.SkillQueryService;
import com.iflytek.skillhub.dto.SkillSummaryResponse;
import com.iflytek.skillhub.service.SkillSearchAppService; import com.iflytek.skillhub.service.SkillSearchAppService;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import java.io.ByteArrayOutputStream;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.zip.ZipEntry; import java.math.BigDecimal;
import java.util.zip.ZipOutputStream; import java.time.LocalDateTime;
import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.Mockito.when;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.BDDMockito.given;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; 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.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest @SpringBootTest
@ -58,44 +46,43 @@ class ClawHubCompatControllerTest {
@MockBean @MockBean
private SkillQueryService skillQueryService; private SkillQueryService skillQueryService;
@MockBean
private SkillPublishService skillPublishService;
@MockBean
private AuditLogService auditLogService;
@Test @Test
void search_returns_200() throws Exception { void search_returns_mapped_results() throws Exception {
given(skillSearchAppService.search("test", null, "relevance", 0, 20, null, Map.of())) when(skillSearchAppService.search("test", null, "relevance", 0, 20, null, null))
.willReturn(new SkillSearchAppService.SearchResponse(List.of(), 0, 0, 20)); .thenReturn(new SkillSearchAppService.SearchResponse(
List.of(new SkillSummaryResponse(
1L,
"my-skill",
"My Skill",
"test summary",
10L,
5,
BigDecimal.valueOf(4.5),
2,
"1.2.0",
"global",
LocalDateTime.of(2026, 3, 13, 9, 0))),
1,
0,
20
));
mockMvc.perform(get("/api/compat/v1/search") mockMvc.perform(get("/api/compat/v1/search")
.param("q", "test")) .param("q", "test"))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.items").isArray()) .andExpect(jsonPath("$.items").isArray())
.andExpect(jsonPath("$.items").isEmpty()); .andExpect(jsonPath("$.items[0].canonicalSlug").value("my-skill"))
.andExpect(jsonPath("$.items[0].description").value("test summary"))
.andExpect(jsonPath("$.items[0].latestVersion").value("1.2.0"))
.andExpect(jsonPath("$.items[0].starCount").value(5));
} }
@Test @Test
void resolve_returns_correct_downloadUrl() throws Exception { void resolve_returns_correct_downloadUrl() throws Exception {
given(skillQueryService.resolveVersion( when(skillQueryService.resolveVersion("global", "my-skill", null, "latest", null, null, java.util.Map.of()))
eq("global"), .thenReturn(new SkillQueryService.ResolvedVersionDTO(
eq("my-skill"), 1L, "global", "my-skill", "latest", 2L, "sha", true, "/api/v1/skills/global/my-skill/download"));
isNull(),
eq("latest"),
isNull(),
isNull(),
eq(Map.<Long, NamespaceRole>of())))
.willReturn(new SkillQueryService.ResolvedVersionDTO(
1L,
"global",
"my-skill",
"latest",
1L,
"sha256:test",
true,
"/api/v1/skills/global/my-skill/download"
));
mockMvc.perform(get("/api/compat/v1/resolve/my-skill")) mockMvc.perform(get("/api/compat/v1/resolve/my-skill"))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.canonicalSlug").value("my-skill")) .andExpect(jsonPath("$.canonicalSlug").value("my-skill"))
@ -105,25 +92,9 @@ class ClawHubCompatControllerTest {
@Test @Test
void resolve_with_namespace_returns_correct_downloadUrl() throws Exception { void resolve_with_namespace_returns_correct_downloadUrl() throws Exception {
given(skillQueryService.resolveVersion( when(skillQueryService.resolveVersion("team-ai", "my-skill", null, "latest", null, null, java.util.Map.of()))
eq("team-ai"), .thenReturn(new SkillQueryService.ResolvedVersionDTO(
eq("my-skill"), 1L, "team-ai", "my-skill", "latest", 2L, "sha", true, "/api/v1/skills/team-ai/my-skill/download"));
isNull(),
eq("latest"),
isNull(),
isNull(),
eq(Map.<Long, NamespaceRole>of())))
.willReturn(new SkillQueryService.ResolvedVersionDTO(
1L,
"team-ai",
"my-skill",
"latest",
1L,
"sha256:test",
true,
"/api/v1/skills/team-ai/my-skill/download"
));
mockMvc.perform(get("/api/compat/v1/resolve/team-ai--my-skill")) mockMvc.perform(get("/api/compat/v1/resolve/team-ai--my-skill"))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.canonicalSlug").value("team-ai--my-skill")) .andExpect(jsonPath("$.canonicalSlug").value("team-ai--my-skill"))
@ -133,25 +104,9 @@ class ClawHubCompatControllerTest {
@Test @Test
void resolve_with_version_returns_specified_version() throws Exception { void resolve_with_version_returns_specified_version() throws Exception {
given(skillQueryService.resolveVersion( when(skillQueryService.resolveVersion("global", "my-skill", "1.0.0", null, null, null, java.util.Map.of()))
eq("global"), .thenReturn(new SkillQueryService.ResolvedVersionDTO(
eq("my-skill"), 1L, "global", "my-skill", "1.0.0", 2L, "sha", true, "/api/v1/skills/global/my-skill/download"));
eq("1.0.0"),
isNull(),
isNull(),
isNull(),
eq(Map.<Long, NamespaceRole>of())))
.willReturn(new SkillQueryService.ResolvedVersionDTO(
1L,
"global",
"my-skill",
"1.0.0",
2L,
"sha256:test",
true,
"/api/v1/skills/global/my-skill/download"
));
mockMvc.perform(get("/api/compat/v1/resolve/my-skill") mockMvc.perform(get("/api/compat/v1/resolve/my-skill")
.param("version", "1.0.0")) .param("version", "1.0.0"))
.andExpect(status().isOk()) .andExpect(status().isOk())
@ -184,62 +139,4 @@ class ClawHubCompatControllerTest {
.andExpect(jsonPath("$.displayName").value("tester")) .andExpect(jsonPath("$.displayName").value("tester"))
.andExpect(jsonPath("$.email").value("tester@example.com")); .andExpect(jsonPath("$.email").value("tester@example.com"));
} }
@Test
void publish_passesSuperAdminRolesToDomainService() throws Exception {
SkillVersion version = new SkillVersion(1L, "1.0.0", "user-42");
version.setStatus(SkillVersionStatus.PUBLISHED);
given(skillPublishService.publishFromEntries(
eq("global"),
anyList(),
eq("user-42"),
eq(SkillVisibility.PUBLIC),
eq(Set.of("SUPER_ADMIN"))))
.willReturn(new SkillPublishService.PublishResult(1L, "demo-skill", version));
PlatformPrincipal principal = new PlatformPrincipal(
"user-42",
"tester",
"tester@example.com",
"",
"github",
Set.of("SUPER_ADMIN")
);
var auth = new UsernamePasswordAuthenticationToken(
principal,
null,
List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN"))
);
MockMultipartFile file = new MockMultipartFile(
"file",
"skill.zip",
"application/zip",
createValidSkillZip()
);
mockMvc.perform(multipart("/api/compat/v1/publish")
.file(file)
.param("namespace", "global")
.with(authentication(auth))
.with(csrf()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("PUBLISHED"));
}
private byte[] createValidSkillZip() throws Exception {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (ZipOutputStream zos = new ZipOutputStream(baos)) {
zos.putNextEntry(new ZipEntry("SKILL.md"));
zos.write("""
---
name: test-skill
version: 1.0.0
---
""".getBytes());
zos.closeEntry();
}
return baos.toByteArray();
}
} }

View file

@ -3,12 +3,6 @@ package com.iflytek.skillhub.controller;
import com.iflytek.skillhub.auth.device.DeviceAuthService; import com.iflytek.skillhub.auth.device.DeviceAuthService;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.metrics.SkillHubMetrics;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
@ -26,11 +20,8 @@ import java.util.Set;
import java.util.zip.ZipEntry; import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream; import java.util.zip.ZipOutputStream;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.given;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; 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.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
@ -49,12 +40,6 @@ class CliControllerTest {
@MockBean @MockBean
private DeviceAuthService deviceAuthService; private DeviceAuthService deviceAuthService;
@MockBean
private SkillPublishService skillPublishService;
@MockBean
private SkillHubMetrics skillHubMetrics;
@MockBean
private AuditLogService auditLogService;
@Test @Test
void whoamiShouldReturnUnauthorizedForAnonymousRequest() throws Exception { void whoamiShouldReturnUnauthorizedForAnonymousRequest() throws Exception {
@ -148,50 +133,22 @@ class CliControllerTest {
} }
@Test @Test
void publishShouldPassPlatformRolesToDomainService() throws Exception { void checkShouldReturnInvalidForPathTraversalEntry() throws Exception {
SkillVersion version = new SkillVersion(12L, "1.0.0", "user-7"); byte[] zipBytes = createZipWithUnsafePath();
version.setStatus(SkillVersionStatus.PUBLISHED);
version.setFileCount(1);
version.setTotalSize(128L);
given(skillPublishService.publishFromEntries(
eq("global"),
anyList(),
eq("user-7"),
eq(SkillVisibility.PUBLIC),
eq(Set.of("SUPER_ADMIN"))))
.willReturn(new SkillPublishService.PublishResult(12L, "demo-skill", version));
PlatformPrincipal principal = new PlatformPrincipal(
"user-7",
"cli-user",
"cli@example.com",
"",
"api_token",
Set.of("SUPER_ADMIN")
);
var auth = new UsernamePasswordAuthenticationToken(
principal,
null,
List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN"))
);
MockMultipartFile file = new MockMultipartFile( MockMultipartFile file = new MockMultipartFile(
"file", "file",
"skill.zip", "skill.zip",
"application/zip", "application/zip",
createValidSkillZip() zipBytes
); );
mockMvc.perform(multipart("/api/v1/cli/publish") mockMvc.perform(multipart("/api/v1/cli/check").file(file))
.file(file)
.param("namespace", "global")
.param("visibility", "PUBLIC")
.with(authentication(auth))
.with(csrf()))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("PUBLISHED")); .andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.valid").value(false))
.andExpect(jsonPath("$.data.errors[0]").value(org.hamcrest.Matchers.containsString("escapes package root")))
.andExpect(jsonPath("$.data.fileCount").value(0))
.andExpect(jsonPath("$.data.totalSize").value(0));
} }
private byte[] createValidSkillZip() throws Exception { private byte[] createValidSkillZip() throws Exception {
@ -253,4 +210,15 @@ class CliControllerTest {
} }
return baos.toByteArray(); return baos.toByteArray();
} }
private byte[] createZipWithUnsafePath() throws Exception {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (ZipOutputStream zos = new ZipOutputStream(baos)) {
ZipEntry unsafeEntry = new ZipEntry("../secrets.txt");
zos.putNextEntry(unsafeEntry);
zos.write("hidden".getBytes());
zos.closeEntry();
}
return baos.toByteArray();
}
} }

View file

@ -70,4 +70,20 @@ class DeviceAuthControllerTest {
.andExpect(jsonPath("$.data.accessToken").isEmpty()) .andExpect(jsonPath("$.data.accessToken").isEmpty())
.andExpect(jsonPath("$.data.tokenType").isEmpty()); .andExpect(jsonPath("$.data.tokenType").isEmpty());
} }
@Test
void pollToken_returns_access_token_when_authorized() throws Exception {
DeviceTokenResponse response = DeviceTokenResponse.success("sk_device_flow_token");
given(deviceAuthService.pollToken("device_abc123")).willReturn(response);
mockMvc.perform(post("/api/v1/cli/auth/device/token")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"deviceCode\": \"device_abc123\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.accessToken").value("sk_device_flow_token"))
.andExpect(jsonPath("$.data.tokenType").value("Bearer"))
.andExpect(jsonPath("$.data.error").isEmpty());
}
} }

View file

@ -0,0 +1,206 @@
package com.iflytek.skillhub.controller;
import com.iflytek.skillhub.auth.device.DeviceAuthService;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.rbac.RbacService;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceMember;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.review.PromotionRequest;
import com.iflytek.skillhub.domain.review.PromotionRequestRepository;
import com.iflytek.skillhub.domain.review.PromotionService;
import com.iflytek.skillhub.domain.review.ReviewPermissionChecker;
import com.iflytek.skillhub.domain.review.ReviewTaskStatus;
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.SkillVisibility;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
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.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.RequestPostProcessor;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import static org.mockito.BDDMockito.given;
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.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class PromotionPortalControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private PromotionService promotionService;
@MockBean
private PromotionRequestRepository promotionRequestRepository;
@MockBean
private SkillRepository skillRepository;
@MockBean
private SkillVersionRepository skillVersionRepository;
@MockBean
private NamespaceMemberRepository namespaceMemberRepository;
@MockBean
private DeviceAuthService deviceAuthService;
@MockBean
private com.iflytek.skillhub.domain.namespace.NamespaceRepository namespaceRepository;
@MockBean
private UserAccountRepository userAccountRepository;
@MockBean
private RbacService rbacService;
@MockBean
private ReviewPermissionChecker permissionChecker;
@MockBean
private AuditLogService auditLogService;
@Test
void submitPromotion_passesNamespaceRolesToService() throws Exception {
PromotionRequest request = createPromotionRequest(1L, "user-1");
stubNamespaceRoles("user-1", List.of(new NamespaceMember(5L, "user-1", NamespaceRole.ADMIN)));
given(rbacService.getUserRoleCodes("user-1")).willReturn(Set.of());
given(promotionService.submitPromotion(10L, 20L, 30L, "user-1", Map.of(5L, NamespaceRole.ADMIN), Set.of()))
.willReturn(request);
stubPromotionResponse(request);
mockMvc.perform(post("/api/v1/promotions")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"sourceSkillId\":10,\"sourceVersionId\":20,\"targetNamespaceId\":30}")
.with(csrf())
.with(auth("user-1")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.id").value(1L));
}
@Test
void listPendingPromotions_forbidsRegularUser() throws Exception {
stubNamespaceRoles("user-1", List.of());
given(rbacService.getUserRoleCodes("user-1")).willReturn(Set.of());
given(permissionChecker.canListPendingPromotions(Set.of())).willReturn(false);
mockMvc.perform(get("/api/v1/promotions/pending").with(auth("user-1")))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value(403));
verify(promotionRequestRepository, never()).findByStatus(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any());
}
@Test
void getPromotionDetail_allowsSubmitter() throws Exception {
PromotionRequest request = createPromotionRequest(1L, "user-1");
stubNamespaceRoles("user-1", List.of());
given(promotionRequestRepository.findById(1L)).willReturn(Optional.of(request));
given(rbacService.getUserRoleCodes("user-1")).willReturn(Set.of());
given(promotionService.canViewPromotion(request, "user-1", Set.of())).willReturn(true);
stubPromotionResponse(request);
mockMvc.perform(get("/api/v1/promotions/1").with(auth("user-1")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.submittedBy").value("user-1"));
}
@Test
void getPromotionDetail_forbidsUnrelatedUser() throws Exception {
PromotionRequest request = createPromotionRequest(1L, "user-1");
stubNamespaceRoles("user-9", List.of());
given(promotionRequestRepository.findById(1L)).willReturn(Optional.of(request));
given(rbacService.getUserRoleCodes("user-9")).willReturn(Set.of());
given(promotionService.canViewPromotion(request, "user-9", Set.of())).willReturn(false);
mockMvc.perform(get("/api/v1/promotions/1").with(auth("user-9")))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value(403));
}
private void stubPromotionResponse(PromotionRequest request) {
Skill skill = new Skill(5L, "skill-a", request.getSubmittedBy(), SkillVisibility.PUBLIC);
setField(skill, "id", request.getSourceSkillId());
SkillVersion version = new SkillVersion(request.getSourceSkillId(), "1.0.0", request.getSubmittedBy());
setField(version, "id", request.getSourceVersionId());
Namespace sourceNamespace = new Namespace("team-a", "Team A", "owner-1");
setField(sourceNamespace, "id", 5L);
Namespace targetNamespace = new Namespace("global", "Global", "owner-2");
setField(targetNamespace, "id", request.getTargetNamespaceId());
UserAccount submitter = new UserAccount(request.getSubmittedBy(), "Submitter", "submitter@example.com", "");
given(skillRepository.findById(request.getSourceSkillId())).willReturn(Optional.of(skill));
given(skillVersionRepository.findById(request.getSourceVersionId())).willReturn(Optional.of(version));
given(namespaceRepository.findById(5L)).willReturn(Optional.of(sourceNamespace));
given(namespaceRepository.findById(request.getTargetNamespaceId())).willReturn(Optional.of(targetNamespace));
given(userAccountRepository.findById(request.getSubmittedBy())).willReturn(Optional.of(submitter));
}
private void stubNamespaceRoles(String userId, List<NamespaceMember> members) {
given(namespaceMemberRepository.findByUserId(userId)).willReturn(members);
}
private RequestPostProcessor auth(String userId) {
PlatformPrincipal principal = new PlatformPrincipal(
userId,
userId,
userId + "@example.com",
"",
"session",
Set.of()
);
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
principal,
null,
List.of(new SimpleGrantedAuthority("ROLE_USER"))
);
return authentication(authenticationToken);
}
private PromotionRequest createPromotionRequest(Long id, String submittedBy) {
PromotionRequest request = new PromotionRequest(10L, 20L, 30L, submittedBy);
setField(request, "id", id);
setField(request, "status", ReviewTaskStatus.PENDING);
return request;
}
private void setField(Object target, String fieldName, Object value) {
try {
java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}

View file

@ -0,0 +1,218 @@
package com.iflytek.skillhub.controller;
import com.iflytek.skillhub.auth.device.DeviceAuthService;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.rbac.RbacService;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceMember;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.review.ReviewPermissionChecker;
import com.iflytek.skillhub.domain.review.ReviewService;
import com.iflytek.skillhub.domain.review.ReviewTask;
import com.iflytek.skillhub.domain.review.ReviewTaskRepository;
import com.iflytek.skillhub.domain.review.ReviewTaskStatus;
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.SkillVisibility;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
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.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.RequestPostProcessor;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import static org.mockito.BDDMockito.given;
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.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class ReviewPortalControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private ReviewService reviewService;
@MockBean
private ReviewTaskRepository reviewTaskRepository;
@MockBean
private SkillRepository skillRepository;
@MockBean
private SkillVersionRepository skillVersionRepository;
@MockBean
private NamespaceMemberRepository namespaceMemberRepository;
@MockBean
private DeviceAuthService deviceAuthService;
@MockBean
private com.iflytek.skillhub.domain.namespace.NamespaceRepository namespaceRepository;
@MockBean
private UserAccountRepository userAccountRepository;
@MockBean
private RbacService rbacService;
@MockBean
private ReviewPermissionChecker permissionChecker;
@MockBean
private AuditLogService auditLogService;
@Test
void submitReview_passesNamespaceRolesToService() throws Exception {
ReviewTask task = createReviewTask(1L, 20L, "user-1");
stubNamespaceRoles("user-1", List.of(new NamespaceMember(20L, "user-1", NamespaceRole.MEMBER)));
given(rbacService.getUserRoleCodes("user-1")).willReturn(Set.of());
given(reviewService.submitReview(100L, "user-1", Map.of(20L, NamespaceRole.MEMBER), Set.of())).willReturn(task);
stubReviewResponse(task);
mockMvc.perform(post("/api/v1/reviews")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"skillVersionId\":100}")
.with(csrf())
.with(auth("user-1")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.id").value(1L));
}
@Test
void listPendingReviews_forbidsNamespaceMember() throws Exception {
Namespace namespace = createNamespace(20L, "team-a");
stubNamespaceRoles("user-1", List.of(new NamespaceMember(20L, "user-1", NamespaceRole.MEMBER)));
given(namespaceRepository.findById(20L)).willReturn(Optional.of(namespace));
given(rbacService.getUserRoleCodes("user-1")).willReturn(Set.of());
given(permissionChecker.canManageNamespaceReviews(
20L,
namespace.getType(),
Map.of(20L, NamespaceRole.MEMBER),
Set.of())).willReturn(false);
mockMvc.perform(get("/api/v1/reviews/pending")
.param("namespaceId", "20")
.with(auth("user-1")))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value(403));
verify(reviewTaskRepository, never()).findByNamespaceIdAndStatus(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any());
}
@Test
void getReviewDetail_allowsSubmitter() throws Exception {
ReviewTask task = createReviewTask(1L, 20L, "user-1");
Namespace namespace = createNamespace(20L, "team-a");
stubNamespaceRoles("user-1", List.of());
given(reviewTaskRepository.findById(1L)).willReturn(Optional.of(task));
given(namespaceRepository.findById(20L)).willReturn(Optional.of(namespace));
given(rbacService.getUserRoleCodes("user-1")).willReturn(Set.of());
given(reviewService.canViewReview(task, "user-1", namespace.getType(), Map.of(), Set.of())).willReturn(true);
stubReviewResponse(task);
mockMvc.perform(get("/api/v1/reviews/1").with(auth("user-1")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.submittedBy").value("user-1"));
}
@Test
void getReviewDetail_forbidsUnrelatedUser() throws Exception {
ReviewTask task = createReviewTask(1L, 20L, "user-1");
Namespace namespace = createNamespace(20L, "team-a");
stubNamespaceRoles("user-9", List.of());
given(reviewTaskRepository.findById(1L)).willReturn(Optional.of(task));
given(namespaceRepository.findById(20L)).willReturn(Optional.of(namespace));
given(rbacService.getUserRoleCodes("user-9")).willReturn(Set.of());
given(reviewService.canViewReview(task, "user-9", namespace.getType(), Map.of(), Set.of())).willReturn(false);
mockMvc.perform(get("/api/v1/reviews/1").with(auth("user-9")))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value(403));
}
private void stubReviewResponse(ReviewTask task) {
SkillVersion version = new SkillVersion(30L, "1.0.0", task.getSubmittedBy());
setField(version, "id", task.getSkillVersionId());
Skill skill = new Skill(task.getNamespaceId(), "skill-a", task.getSubmittedBy(), SkillVisibility.PUBLIC);
setField(skill, "id", 30L);
UserAccount submitter = new UserAccount(task.getSubmittedBy(), "Submitter", "submitter@example.com", "");
given(skillVersionRepository.findById(task.getSkillVersionId())).willReturn(Optional.of(version));
given(skillRepository.findById(30L)).willReturn(Optional.of(skill));
given(namespaceRepository.findById(task.getNamespaceId())).willReturn(Optional.of(createNamespace(task.getNamespaceId(), "team-a")));
given(userAccountRepository.findById(task.getSubmittedBy())).willReturn(Optional.of(submitter));
}
private void stubNamespaceRoles(String userId, List<NamespaceMember> members) {
given(namespaceMemberRepository.findByUserId(userId)).willReturn(members);
}
private RequestPostProcessor auth(String userId) {
PlatformPrincipal principal = new PlatformPrincipal(
userId,
userId,
userId + "@example.com",
"",
"session",
Set.of()
);
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
principal,
null,
List.of(new SimpleGrantedAuthority("ROLE_USER"))
);
return authentication(authenticationToken);
}
private ReviewTask createReviewTask(Long id, Long namespaceId, String submittedBy) {
ReviewTask task = new ReviewTask(100L, namespaceId, submittedBy);
setField(task, "id", id);
setField(task, "status", ReviewTaskStatus.PENDING);
return task;
}
private Namespace createNamespace(Long id, String slug) {
Namespace namespace = new Namespace(slug, "Team", "owner-1");
setField(namespace, "id", id);
return namespace;
}
private void setField(Object target, String fieldName, Object value) {
try {
java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}

View file

@ -1,7 +1,6 @@
package com.iflytek.skillhub.controller; package com.iflytek.skillhub.controller;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.domain.social.SkillRatingService; import com.iflytek.skillhub.domain.social.SkillRatingService;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@ -110,31 +109,8 @@ class SkillRatingControllerTest {
} }
@Test @Test
void get_user_rating_missing_skill_returns_404_envelope() throws Exception { void get_user_rating_unauthenticated_returns_401() throws Exception {
PlatformPrincipal principal = new PlatformPrincipal( mockMvc.perform(get("/api/v1/skills/10/rating"))
"user-42", .andExpect(status().isUnauthorized());
"tester",
"tester@example.com",
"https://example.com/avatar.png",
"github",
Set.of("SUPER_ADMIN")
);
var auth = new UsernamePasswordAuthenticationToken(
principal,
null,
List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN"))
);
when(skillRatingService.getUserRating(eq(999L), eq("user-42")))
.thenThrow(new DomainNotFoundException("skill.not_found", 999L));
mockMvc.perform(get("/api/v1/skills/999/rating")
.with(authentication(auth))
.with(csrf()))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(404))
.andExpect(jsonPath("$.msg").value("Skill not found: 999"))
.andExpect(jsonPath("$.timestamp").isNotEmpty())
.andExpect(jsonPath("$.requestId").isNotEmpty());
} }
} }

View file

@ -1,7 +1,6 @@
package com.iflytek.skillhub.controller; package com.iflytek.skillhub.controller;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.domain.social.SkillStarService; import com.iflytek.skillhub.domain.social.SkillStarService;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@ -129,31 +128,8 @@ class SkillStarControllerTest {
} }
@Test @Test
void check_starred_missing_skill_returns_404_envelope() throws Exception { void check_starred_unauthenticated_returns_401() throws Exception {
PlatformPrincipal principal = new PlatformPrincipal( mockMvc.perform(get("/api/v1/skills/10/star"))
"user-42", .andExpect(status().isUnauthorized());
"tester",
"tester@example.com",
"https://example.com/avatar.png",
"github",
Set.of("SUPER_ADMIN")
);
var auth = new UsernamePasswordAuthenticationToken(
principal,
null,
List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN"))
);
when(skillStarService.isStarred(eq(999L), eq("user-42")))
.thenThrow(new DomainNotFoundException("skill.not_found", 999L));
mockMvc.perform(get("/api/v1/skills/999/star")
.with(authentication(auth))
.with(csrf()))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(404))
.andExpect(jsonPath("$.msg").value("Skill not found: 999"))
.andExpect(jsonPath("$.timestamp").isNotEmpty())
.andExpect(jsonPath("$.requestId").isNotEmpty());
} }
} }

View file

@ -0,0 +1,50 @@
package com.iflytek.skillhub.controller;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.domain.skill.SkillTag;
import com.iflytek.skillhub.domain.skill.service.SkillTagService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.Map;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class SkillTagControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private SkillTagService skillTagService;
@MockBean
private NamespaceMemberRepository namespaceMemberRepository;
@Test
void list_tags_is_public() throws Exception {
when(skillTagService.listTags(eq("team"), eq("demo"), isNull(), eq(Map.of())))
.thenReturn(List.of(new SkillTag(1L, "latest", 2L, "user-1")));
mockMvc.perform(get("/api/v1/skills/team/demo/tags"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data[0].tagName").value("latest"))
.andExpect(jsonPath("$.timestamp").isNotEmpty())
.andExpect(jsonPath("$.requestId").isNotEmpty());
}
}

View file

@ -0,0 +1,64 @@
package com.iflytek.skillhub.controller;
import com.iflytek.skillhub.TestRedisConfig;
import com.iflytek.skillhub.auth.device.DeviceAuthService;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.token.ApiTokenService;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.Set;
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;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
@Import(TestRedisConfig.class)
class TokenControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private NamespaceMemberRepository namespaceMemberRepository;
@MockBean
private DeviceAuthService deviceAuthService;
@MockBean
private ApiTokenService apiTokenService;
@Test
void revoke_returns204NoContent() throws Exception {
PlatformPrincipal principal = new PlatformPrincipal(
"user-42", "tester", "tester@example.com", "", "github", Set.of("USER")
);
var auth = new UsernamePasswordAuthenticationToken(
principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER"))
);
mockMvc.perform(delete("/api/v1/tokens/7")
.with(authentication(auth))
.with(csrf()))
.andExpect(status().isNoContent())
.andExpect(content().string(""));
verify(apiTokenService).revokeToken(7L, "user-42");
}
}

View file

@ -3,27 +3,26 @@ package com.iflytek.skillhub.controller.admin;
import com.iflytek.skillhub.TestRedisConfig; import com.iflytek.skillhub.TestRedisConfig;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.device.DeviceAuthService; import com.iflytek.skillhub.auth.device.DeviceAuthService;
import com.iflytek.skillhub.domain.audit.AuditLog;
import com.iflytek.skillhub.domain.audit.AuditLogQueryService;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.dto.AuditLogItemResponse;
import com.iflytek.skillhub.dto.PageResponse;
import com.iflytek.skillhub.service.AdminAuditLogAppService;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import; import org.springframework.context.annotation.Import;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
import java.time.Instant;
import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
@ -45,7 +44,7 @@ class AuditLogControllerTest {
private DeviceAuthService deviceAuthService; private DeviceAuthService deviceAuthService;
@MockBean @MockBean
private AuditLogQueryService auditLogQueryService; private AdminAuditLogAppService adminAuditLogAppService;
@Test @Test
void listAuditLogs_unauthenticated_returns401() throws Exception { void listAuditLogs_unauthenticated_returns401() throws Exception {
@ -55,15 +54,6 @@ class AuditLogControllerTest {
@Test @Test
void listAuditLogs_withAuditorRole_returns200() throws Exception { void listAuditLogs_withAuditorRole_returns200() throws Exception {
AuditLog log1 = new AuditLog("user-1", "CREATE_SKILL", "SKILL", 123L, null, "192.168.1.1", "", null);
AuditLog log2 = new AuditLog("user-2", "UPDATE_NAMESPACE", "NAMESPACE", 456L, null, "192.168.1.2", "", null);
org.springframework.test.util.ReflectionTestUtils.setField(log1, "id", 1L);
org.springframework.test.util.ReflectionTestUtils.setField(log2, "id", 2L);
org.springframework.test.util.ReflectionTestUtils.setField(log1, "createdAt", Instant.now());
org.springframework.test.util.ReflectionTestUtils.setField(log2, "createdAt", Instant.now());
given(auditLogQueryService.list(0, 20, null, null))
.willReturn(new PageImpl<>(List.of(log1, log2), PageRequest.of(0, 20), 2));
PlatformPrincipal principal = new PlatformPrincipal( PlatformPrincipal principal = new PlatformPrincipal(
"user-50", "auditor", "auditor@example.com", "", "github", Set.of("AUDITOR") "user-50", "auditor", "auditor@example.com", "", "github", Set.of("AUDITOR")
); );
@ -71,17 +61,31 @@ class AuditLogControllerTest {
principal, null, List.of(new SimpleGrantedAuthority("ROLE_AUDITOR")) principal, null, List.of(new SimpleGrantedAuthority("ROLE_AUDITOR"))
); );
when(adminAuditLogAppService.listAuditLogs(0, 20, null, null))
.thenReturn(new PageResponse<>(
List.of(new AuditLogItemResponse(
1L,
"USER_STATUS_CHANGE",
"user-1",
"alice",
"{\"status\":\"DISABLED\"}",
"127.0.0.1",
Instant.parse("2026-03-13T01:00:00Z"))),
1,
0,
20));
mockMvc.perform(get("/api/v1/admin/audit-logs").with(authentication(auth))) mockMvc.perform(get("/api/v1/admin/audit-logs").with(authentication(auth)))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.items").isArray()) .andExpect(jsonPath("$.data.items").isArray())
.andExpect(jsonPath("$.data.total").value(2)); .andExpect(jsonPath("$.data.total").value(1))
.andExpect(jsonPath("$.data.items[0].username").value("alice"))
.andExpect(jsonPath("$.data.items[0].details").value("{\"status\":\"DISABLED\"}"));
} }
@Test @Test
void listAuditLogs_withSuperAdminRole_returns200() throws Exception { void listAuditLogs_withSuperAdminRole_returns200() throws Exception {
given(auditLogQueryService.list(0, 20, null, null))
.willReturn(new PageImpl<>(List.of(), PageRequest.of(0, 20), 0));
PlatformPrincipal principal = new PlatformPrincipal( PlatformPrincipal principal = new PlatformPrincipal(
"user-99", "superadmin", "super@example.com", "", "github", Set.of("SUPER_ADMIN") "user-99", "superadmin", "super@example.com", "", "github", Set.of("SUPER_ADMIN")
); );
@ -89,6 +93,9 @@ class AuditLogControllerTest {
principal, null, List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN")) principal, null, List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN"))
); );
when(adminAuditLogAppService.listAuditLogs(0, 20, null, null))
.thenReturn(new PageResponse<>(List.of(), 0, 0, 20));
mockMvc.perform(get("/api/v1/admin/audit-logs").with(authentication(auth))) mockMvc.perform(get("/api/v1/admin/audit-logs").with(authentication(auth)))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.data.items").isArray()); .andExpect(jsonPath("$.data.items").isArray());
@ -96,8 +103,6 @@ class AuditLogControllerTest {
@Test @Test
void listAuditLogs_withFilters_returns200() throws Exception { void listAuditLogs_withFilters_returns200() throws Exception {
given(auditLogQueryService.list(0, 20, "user-1", "CREATE_SKILL"))
.willReturn(new PageImpl<>(List.of(), PageRequest.of(0, 20), 0));
PlatformPrincipal principal = new PlatformPrincipal( PlatformPrincipal principal = new PlatformPrincipal(
"user-50", "auditor", "auditor@example.com", "", "github", Set.of("AUDITOR") "user-50", "auditor", "auditor@example.com", "", "github", Set.of("AUDITOR")
); );
@ -105,6 +110,9 @@ class AuditLogControllerTest {
principal, null, List.of(new SimpleGrantedAuthority("ROLE_AUDITOR")) principal, null, List.of(new SimpleGrantedAuthority("ROLE_AUDITOR"))
); );
when(adminAuditLogAppService.listAuditLogs(0, 20, "user-1", "CREATE_SKILL"))
.thenReturn(new PageResponse<>(List.of(), 0, 0, 20));
mockMvc.perform(get("/api/v1/admin/audit-logs") mockMvc.perform(get("/api/v1/admin/audit-logs")
.param("userId", "user-1") .param("userId", "user-1")
.param("action", "CREATE_SKILL") .param("action", "CREATE_SKILL")

View file

@ -4,9 +4,10 @@ import com.iflytek.skillhub.TestRedisConfig;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.device.DeviceAuthService; import com.iflytek.skillhub.auth.device.DeviceAuthService;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.dto.AdminUserMutationResponse;
import com.iflytek.skillhub.dto.AdminUserSummaryResponse; import com.iflytek.skillhub.dto.AdminUserSummaryResponse;
import com.iflytek.skillhub.dto.PageResponse; import com.iflytek.skillhub.dto.PageResponse;
import com.iflytek.skillhub.service.AdminUserManagementService; import com.iflytek.skillhub.service.AdminUserAppService;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
@ -22,7 +23,6 @@ import java.util.List;
import java.util.Set; import java.util.Set;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import static org.mockito.BDDMockito.given;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; 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.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
@ -30,6 +30,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.http.MediaType.APPLICATION_JSON; import static org.springframework.http.MediaType.APPLICATION_JSON;
import static org.mockito.Mockito.when;
@SpringBootTest @SpringBootTest
@AutoConfigureMockMvc @AutoConfigureMockMvc
@ -47,7 +48,7 @@ class UserManagementControllerTest {
private DeviceAuthService deviceAuthService; private DeviceAuthService deviceAuthService;
@MockBean @MockBean
private AdminUserManagementService adminUserManagementService; private AdminUserAppService adminUserAppService;
@Test @Test
void listUsers_unauthenticated_returns401() throws Exception { void listUsers_unauthenticated_returns401() throws Exception {
@ -57,17 +58,6 @@ class UserManagementControllerTest {
@Test @Test
void listUsers_withUserAdminRole_returns200() throws Exception { void listUsers_withUserAdminRole_returns200() throws Exception {
given(adminUserManagementService.listUsers(null, null, 0, 20))
.willReturn(new PageResponse<>(
List.of(
new AdminUserSummaryResponse("user-1", "alice", "alice@example.com", List.of("USER"), "ACTIVE", LocalDateTime.parse("2026-03-12T12:00:00")),
new AdminUserSummaryResponse("user-2", "bob", "bob@example.com", List.of("USER_ADMIN"), "PENDING", LocalDateTime.parse("2026-03-12T13:00:00"))
),
2,
0,
20
));
PlatformPrincipal principal = new PlatformPrincipal( PlatformPrincipal principal = new PlatformPrincipal(
"user-42", "admin", "admin@example.com", "", "github", Set.of("USER_ADMIN") "user-42", "admin", "admin@example.com", "", "github", Set.of("USER_ADMIN")
); );
@ -75,23 +65,31 @@ class UserManagementControllerTest {
principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER_ADMIN")) principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER_ADMIN"))
); );
when(adminUserAppService.listUsers(null, null, 0, 20))
.thenReturn(new PageResponse<>(
List.of(new AdminUserSummaryResponse(
"user-1",
"alice",
"alice@example.com",
"ACTIVE",
List.of("AUDITOR"),
LocalDateTime.of(2026, 3, 13, 9, 0))),
1,
0,
20));
mockMvc.perform(get("/api/v1/admin/users").with(authentication(auth))) mockMvc.perform(get("/api/v1/admin/users").with(authentication(auth)))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.items").isArray()) .andExpect(jsonPath("$.data.items").isArray())
.andExpect(jsonPath("$.data.total").value(2)); .andExpect(jsonPath("$.data.total").value(1))
.andExpect(jsonPath("$.data.items[0].id").value("user-1"))
.andExpect(jsonPath("$.data.items[0].email").value("alice@example.com"))
.andExpect(jsonPath("$.data.items[0].platformRoles[0]").value("AUDITOR"));
} }
@Test @Test
void listUsers_withSuperAdminRole_returns200() throws Exception { void listUsers_withSuperAdminRole_returns200() throws Exception {
given(adminUserManagementService.listUsers(null, null, 0, 20))
.willReturn(new PageResponse<>(
List.of(new AdminUserSummaryResponse("user-99", "superadmin", "super@example.com", List.of("SUPER_ADMIN"), "ACTIVE", LocalDateTime.parse("2026-03-12T14:00:00"))),
1,
0,
20
));
PlatformPrincipal principal = new PlatformPrincipal( PlatformPrincipal principal = new PlatformPrincipal(
"user-99", "superadmin", "super@example.com", "", "github", Set.of("SUPER_ADMIN") "user-99", "superadmin", "super@example.com", "", "github", Set.of("SUPER_ADMIN")
); );
@ -99,6 +97,9 @@ class UserManagementControllerTest {
principal, null, List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN")) principal, null, List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN"))
); );
when(adminUserAppService.listUsers(null, null, 0, 20))
.thenReturn(new PageResponse<>(List.of(), 0, 0, 20));
mockMvc.perform(get("/api/v1/admin/users").with(authentication(auth))) mockMvc.perform(get("/api/v1/admin/users").with(authentication(auth)))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.data.items").isArray()); .andExpect(jsonPath("$.data.items").isArray());
@ -106,9 +107,6 @@ class UserManagementControllerTest {
@Test @Test
void updateUserRole_withUserAdminRole_returns200() throws Exception { void updateUserRole_withUserAdminRole_returns200() throws Exception {
given(adminUserManagementService.updateUserRole(org.mockito.ArgumentMatchers.eq("user-123"), org.mockito.ArgumentMatchers.eq("USER_ADMIN"), org.mockito.ArgumentMatchers.any()))
.willReturn(new AdminUserSummaryResponse("user-123", "target", "target@example.com", List.of("USER_ADMIN"), "ACTIVE", LocalDateTime.parse("2026-03-12T15:00:00")));
PlatformPrincipal principal = new PlatformPrincipal( PlatformPrincipal principal = new PlatformPrincipal(
"user-42", "admin", "admin@example.com", "", "github", Set.of("USER_ADMIN") "user-42", "admin", "admin@example.com", "", "github", Set.of("USER_ADMIN")
); );
@ -116,7 +114,10 @@ class UserManagementControllerTest {
principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER_ADMIN")) principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER_ADMIN"))
); );
String requestBody = "{\"role\":\"USER_ADMIN\"}"; String requestBody = "{\"role\":\"MODERATOR\"}";
when(adminUserAppService.updateUserRole("user-123", "MODERATOR", Set.of("USER_ADMIN")))
.thenReturn(new AdminUserMutationResponse("user-123", "MODERATOR", "ACTIVE"));
mockMvc.perform(put("/api/v1/admin/users/user-123/role") mockMvc.perform(put("/api/v1/admin/users/user-123/role")
.with(authentication(auth)) .with(authentication(auth))
@ -126,14 +127,12 @@ class UserManagementControllerTest {
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.userId").value("user-123")) .andExpect(jsonPath("$.data.userId").value("user-123"))
.andExpect(jsonPath("$.data.role").value("USER_ADMIN")); .andExpect(jsonPath("$.data.role").value("MODERATOR"))
.andExpect(jsonPath("$.data.status").value("ACTIVE"));
} }
@Test @Test
void updateUserStatus_withUserAdminRole_returns200() throws Exception { void updateUserStatus_withUserAdminRole_returns200() throws Exception {
given(adminUserManagementService.updateUserStatus("user-123", "DISABLED"))
.willReturn(new AdminUserSummaryResponse("user-123", "target", "target@example.com", List.of("USER"), "DISABLED", LocalDateTime.parse("2026-03-12T16:00:00")));
PlatformPrincipal principal = new PlatformPrincipal( PlatformPrincipal principal = new PlatformPrincipal(
"user-42", "admin", "admin@example.com", "", "github", Set.of("USER_ADMIN") "user-42", "admin", "admin@example.com", "", "github", Set.of("USER_ADMIN")
); );
@ -143,6 +142,9 @@ class UserManagementControllerTest {
String requestBody = "{\"status\":\"DISABLED\"}"; String requestBody = "{\"status\":\"DISABLED\"}";
when(adminUserAppService.updateUserStatus("user-123", "DISABLED"))
.thenReturn(new AdminUserMutationResponse("user-123", null, "DISABLED"));
mockMvc.perform(put("/api/v1/admin/users/user-123/status") mockMvc.perform(put("/api/v1/admin/users/user-123/status")
.with(authentication(auth)) .with(authentication(auth))
.with(csrf()) .with(csrf())

View file

@ -0,0 +1,61 @@
package com.iflytek.skillhub.controller.support;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockMultipartFile;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class SkillPackageArchiveExtractorTest {
private final SkillPackageArchiveExtractor extractor = new SkillPackageArchiveExtractor();
@Test
void shouldRejectPathTraversalEntry() throws Exception {
MockMultipartFile file = new MockMultipartFile(
"file",
"skill.zip",
"application/zip",
createZip("../secrets.txt", "hidden")
);
IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> extractor.extract(file));
assertTrue(error.getMessage().contains("escapes package root"));
}
@Test
void shouldRejectOversizedZipEntry() throws Exception {
byte[] content = new byte[1024 * 1024 + 1];
MockMultipartFile file = new MockMultipartFile(
"file",
"skill.zip",
"application/zip",
createZip("large.txt", content)
);
IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> extractor.extract(file));
assertTrue(error.getMessage().contains("File too large: large.txt"));
}
private byte[] createZip(String entryName, String content) throws Exception {
return createZip(entryName, content.getBytes(StandardCharsets.UTF_8));
}
private byte[] createZip(String entryName, byte[] content) throws Exception {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (ZipOutputStream zos = new ZipOutputStream(baos)) {
ZipEntry entry = new ZipEntry(entryName);
zos.putNextEntry(entry);
zos.write(content);
zos.closeEntry();
}
return baos.toByteArray();
}
}

View file

@ -0,0 +1,44 @@
package com.iflytek.skillhub.service;
import com.iflytek.skillhub.dto.AuditLogItemResponse;
import com.iflytek.skillhub.dto.PageResponse;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import java.time.Instant;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class AdminAuditLogAppServiceTest {
private final NamedParameterJdbcTemplate jdbcTemplate = mock(NamedParameterJdbcTemplate.class);
private final AdminAuditLogAppService service = new AdminAuditLogAppService(jdbcTemplate);
@Test
void listAuditLogs_returnsJdbcBackedPage() {
when(jdbcTemplate.queryForObject(contains("COUNT(*)"), any(MapSqlParameterSource.class), eq(Long.class)))
.thenReturn(1L);
when(jdbcTemplate.query(contains("FROM audit_log"), any(MapSqlParameterSource.class), any(RowMapper.class)))
.thenReturn(List.of(new AuditLogItemResponse(
1L,
"USER_STATUS_CHANGE",
"user-1",
"alice",
"{\"status\":\"DISABLED\"}",
"127.0.0.1",
Instant.parse("2026-03-13T01:00:00Z")
)));
PageResponse<?> response = service.listAuditLogs(0, 20, "user-1", "USER_STATUS_CHANGE");
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));
}
}

View file

@ -0,0 +1,147 @@
package com.iflytek.skillhub.service;
import com.iflytek.skillhub.auth.entity.Role;
import com.iflytek.skillhub.auth.entity.UserRoleBinding;
import com.iflytek.skillhub.auth.repository.RoleRepository;
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.domain.user.UserStatus;
import com.iflytek.skillhub.dto.PageResponse;
import com.iflytek.skillhub.repository.AdminUserSearchRepository;
import org.junit.jupiter.api.Test;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
class AdminUserAppServiceTest {
private final AdminUserSearchRepository adminUserSearchRepository = mock(AdminUserSearchRepository.class);
private final UserRoleBindingRepository userRoleBindingRepository = mock(UserRoleBindingRepository.class);
private final RoleRepository roleRepository = mock(RoleRepository.class);
private final UserAccountRepository userAccountRepository = mock(UserAccountRepository.class);
private final AdminUserAppService service = new AdminUserAppService(
adminUserSearchRepository,
userAccountRepository,
userRoleBindingRepository,
roleRepository
);
@Test
void listUsers_returnsPagedUsersFromRepository() {
UserAccount user = user("user-1", "alice", "alice@example.com", UserStatus.ACTIVE);
PageRequest pageable = PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "createdAt"));
when(adminUserSearchRepository.search("ali", UserStatus.ACTIVE, pageable))
.thenReturn(new PageImpl<>(List.of(user), pageable, 1));
when(userRoleBindingRepository.findByUserIdIn(List.of("user-1")))
.thenReturn(List.of(new UserRoleBinding("user-1", role("AUDITOR"))));
PageResponse<?> response = service.listUsers("ali", "ACTIVE", 0, 20);
assertThat(response.total()).isEqualTo(1);
assertThat(response.items()).hasSize(1);
assertThat(response.items().get(0)).extracting("id", "username", "email", "status")
.containsExactly("user-1", "alice", "alice@example.com", "ACTIVE");
assertThat(response.items().get(0)).extracting("platformRoles")
.isEqualTo(List.of("AUDITOR"));
}
@Test
void listUsers_withInvalidStatus_throwsBadRequest() {
assertThrows(DomainBadRequestException.class, () -> service.listUsers(null, "BANNED", 0, 20));
}
@Test
void updateUserRole_nonSuperAdminCannotAssignSuperAdmin() {
when(userAccountRepository.findById("user-1"))
.thenReturn(Optional.of(user("user-1", "alice", "alice@example.com", UserStatus.ACTIVE)));
assertThrows(DomainForbiddenException.class,
() -> service.updateUserRole("user-1", "SUPER_ADMIN", Set.of("USER_ADMIN")));
}
@Test
void updateUserRole_replacesExistingBindings() {
when(userAccountRepository.findById("user-1"))
.thenReturn(Optional.of(user("user-1", "alice", "alice@example.com", UserStatus.ACTIVE)));
when(roleRepository.findByCode("AUDITOR")).thenReturn(Optional.of(role("AUDITOR")));
var response = service.updateUserRole("user-1", "AUDITOR", Set.of("SUPER_ADMIN"));
verify(userRoleBindingRepository).deleteByUserId("user-1");
verify(userRoleBindingRepository).save(any(UserRoleBinding.class));
assertThat(response.userId()).isEqualTo("user-1");
assertThat(response.role()).isEqualTo("AUDITOR");
assertThat(response.status()).isEqualTo("ACTIVE");
}
@Test
void updateUserRole_userPseudoRoleClearsBindingsWithoutSavingNewRole() {
when(userAccountRepository.findById("user-1"))
.thenReturn(Optional.of(user("user-1", "alice", "alice@example.com", UserStatus.ACTIVE)));
var response = service.updateUserRole("user-1", "USER", Set.of("SUPER_ADMIN"));
verify(userRoleBindingRepository).deleteByUserId("user-1");
verify(userRoleBindingRepository, never()).save(any(UserRoleBinding.class));
assertThat(response.role()).isEqualTo("USER");
}
@Test
void updateUserStatus_rejectsUnsupportedStatuses() {
when(userAccountRepository.findById("user-1"))
.thenReturn(Optional.of(user("user-1", "alice", "alice@example.com", UserStatus.ACTIVE)));
assertThrows(DomainBadRequestException.class, () -> service.updateUserStatus("user-1", "MERGED"));
}
@Test
void updateUserStatus_updatesPersistedStatus() {
UserAccount user = user("user-1", "alice", "alice@example.com", UserStatus.ACTIVE);
when(userAccountRepository.findById("user-1")).thenReturn(Optional.of(user));
when(userAccountRepository.save(user)).thenReturn(user);
var response = service.updateUserStatus("user-1", "DISABLED");
verify(userAccountRepository).save(user);
assertThat(user.getStatus()).isEqualTo(UserStatus.DISABLED);
assertThat(response.status()).isEqualTo("DISABLED");
}
@Test
void updateUserStatus_withUnknownUser_throwsNotFound() {
when(userAccountRepository.findById("missing")).thenReturn(Optional.empty());
assertThrows(DomainNotFoundException.class, () -> service.updateUserStatus("missing", "DISABLED"));
}
private UserAccount user(String id, String displayName, String email, UserStatus status) {
UserAccount user = new UserAccount(id, displayName, email, null);
user.setStatus(status);
ReflectionTestUtils.setField(user, "createdAt", LocalDateTime.of(2026, 3, 13, 9, 0));
ReflectionTestUtils.setField(user, "updatedAt", LocalDateTime.of(2026, 3, 13, 9, 0));
return user;
}
private Role role(String code) {
Role role = new Role();
ReflectionTestUtils.setField(role, "code", code);
ReflectionTestUtils.setField(role, "name", code);
return role;
}
}

View file

@ -6,6 +6,7 @@ import com.iflytek.skillhub.auth.oauth.OAuth2LoginSuccessHandler;
import com.iflytek.skillhub.auth.oauth.SkillHubOAuth2AuthorizationRequestResolver; import com.iflytek.skillhub.auth.oauth.SkillHubOAuth2AuthorizationRequestResolver;
import com.iflytek.skillhub.auth.mock.MockAuthFilter; import com.iflytek.skillhub.auth.mock.MockAuthFilter;
import com.iflytek.skillhub.auth.token.ApiTokenAuthenticationFilter; import com.iflytek.skillhub.auth.token.ApiTokenAuthenticationFilter;
import com.iflytek.skillhub.auth.token.ApiTokenScopeFilter;
import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.ObjectProvider;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
@ -35,6 +36,7 @@ public class SecurityConfig {
private final OAuth2LoginSuccessHandler successHandler; private final OAuth2LoginSuccessHandler successHandler;
private final OAuth2LoginFailureHandler failureHandler; private final OAuth2LoginFailureHandler failureHandler;
private final ApiTokenAuthenticationFilter apiTokenAuthenticationFilter; private final ApiTokenAuthenticationFilter apiTokenAuthenticationFilter;
private final ApiTokenScopeFilter apiTokenScopeFilter;
private final AuthenticationEntryPoint apiAuthenticationEntryPoint; private final AuthenticationEntryPoint apiAuthenticationEntryPoint;
private final AccessDeniedHandler apiAccessDeniedHandler; private final AccessDeniedHandler apiAccessDeniedHandler;
private final ObjectProvider<MockAuthFilter> mockAuthFilterProvider; private final ObjectProvider<MockAuthFilter> mockAuthFilterProvider;
@ -44,6 +46,7 @@ public class SecurityConfig {
OAuth2LoginSuccessHandler successHandler, OAuth2LoginSuccessHandler successHandler,
OAuth2LoginFailureHandler failureHandler, OAuth2LoginFailureHandler failureHandler,
ApiTokenAuthenticationFilter apiTokenAuthenticationFilter, ApiTokenAuthenticationFilter apiTokenAuthenticationFilter,
ApiTokenScopeFilter apiTokenScopeFilter,
AuthenticationEntryPoint apiAuthenticationEntryPoint, AuthenticationEntryPoint apiAuthenticationEntryPoint,
AccessDeniedHandler apiAccessDeniedHandler, AccessDeniedHandler apiAccessDeniedHandler,
ObjectProvider<MockAuthFilter> mockAuthFilterProvider) { ObjectProvider<MockAuthFilter> mockAuthFilterProvider) {
@ -52,6 +55,7 @@ public class SecurityConfig {
this.successHandler = successHandler; this.successHandler = successHandler;
this.failureHandler = failureHandler; this.failureHandler = failureHandler;
this.apiTokenAuthenticationFilter = apiTokenAuthenticationFilter; this.apiTokenAuthenticationFilter = apiTokenAuthenticationFilter;
this.apiTokenScopeFilter = apiTokenScopeFilter;
this.apiAuthenticationEntryPoint = apiAuthenticationEntryPoint; this.apiAuthenticationEntryPoint = apiAuthenticationEntryPoint;
this.apiAccessDeniedHandler = apiAccessDeniedHandler; this.apiAccessDeniedHandler = apiAccessDeniedHandler;
this.mockAuthFilterProvider = mockAuthFilterProvider; this.mockAuthFilterProvider = mockAuthFilterProvider;
@ -88,7 +92,9 @@ public class SecurityConfig {
"/api/compat/v1/resolve/**", "/api/compat/v1/resolve/**",
"/api/compat/v1/download/**" "/api/compat/v1/download/**"
).permitAll() ).permitAll()
.requestMatchers(HttpMethod.GET, .requestMatchers(HttpMethod.GET, "/api/v1/skills/*/star", "/api/v1/skills/*/rating").authenticated()
.requestMatchers(
HttpMethod.GET,
"/api/v1/skills", "/api/v1/skills",
"/api/v1/skills/*/*", "/api/v1/skills/*/*",
"/api/v1/skills/*/*/versions", "/api/v1/skills/*/*/versions",
@ -134,7 +140,8 @@ public class SecurityConfig {
.invalidateHttpSession(true) .invalidateHttpSession(true)
.deleteCookies("SESSION") .deleteCookies("SESSION")
) )
.addFilterBefore(apiTokenAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); .addFilterBefore(apiTokenAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterAfter(apiTokenScopeFilter, ApiTokenAuthenticationFilter.class);
MockAuthFilter mockAuthFilter = mockAuthFilterProvider.getIfAvailable(); MockAuthFilter mockAuthFilter = mockAuthFilterProvider.getIfAvailable();
if (mockAuthFilter != null) { if (mockAuthFilter != null) {

View file

@ -1,15 +1,11 @@
package com.iflytek.skillhub.auth.device; package com.iflytek.skillhub.auth.device;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.auth.token.ApiTokenService; import com.iflytek.skillhub.auth.token.ApiTokenService;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisOperations;
import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.SessionCallback;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.util.Base64; import java.util.Base64;
@ -19,32 +15,27 @@ import java.util.concurrent.TimeUnit;
public class DeviceAuthService { public class DeviceAuthService {
private static final String DEVICE_CODE_PREFIX = "device:code:"; private static final String DEVICE_CODE_PREFIX = "device:code:";
private static final String DEVICE_CLAIM_PREFIX = "device:claim:";
private static final String USER_CODE_PREFIX = "device:usercode:"; private static final String USER_CODE_PREFIX = "device:usercode:";
private static final int EXPIRES_IN_SECONDS = 900; private static final int EXPIRES_IN_SECONDS = 900;
private static final int POLL_INTERVAL_SECONDS = 5; private static final int POLL_INTERVAL_SECONDS = 5;
private static final String USER_CODE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; private static final String USER_CODE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
private static final long PENDING_CODE_TTL_MINUTES = EXPIRES_IN_SECONDS / 60L;
private static final long USED_CODE_TTL_MINUTES = 1L;
private static final String CLI_DEVICE_TOKEN_NAME = "CLI Device Flow";
private static final String CLI_DEVICE_SCOPE_JSON = "[\"skill:read\",\"skill:publish\"]";
private final RedisTemplate<String, Object> redisTemplate; private final RedisTemplate<String, Object> redisTemplate;
private final String verificationUri;
private final ObjectMapper objectMapper;
private final ApiTokenService apiTokenService; private final ApiTokenService apiTokenService;
private final String verificationUri;
private final SecureRandom random = new SecureRandom(); private final SecureRandom random = new SecureRandom();
@Autowired
public DeviceAuthService(RedisTemplate<String, Object> redisTemplate, public DeviceAuthService(RedisTemplate<String, Object> redisTemplate,
ApiTokenService apiTokenService, ApiTokenService apiTokenService,
@Value("${skillhub.device-auth.verification-uri:/device}") String verificationUri) { @Value("${skillhub.device-auth.verification-uri:/device}") String verificationUri) {
this(redisTemplate, apiTokenService, verificationUri, new ObjectMapper());
}
public DeviceAuthService(RedisTemplate<String, Object> redisTemplate,
ApiTokenService apiTokenService,
String verificationUri,
ObjectMapper objectMapper) {
this.redisTemplate = redisTemplate; this.redisTemplate = redisTemplate;
this.verificationUri = verificationUri;
this.objectMapper = objectMapper;
this.apiTokenService = apiTokenService; this.apiTokenService = apiTokenService;
this.verificationUri = verificationUri;
} }
public DeviceCodeResponse generateDeviceCode() { public DeviceCodeResponse generateDeviceCode() {
@ -54,9 +45,9 @@ public class DeviceAuthService {
DeviceCodeData data = new DeviceCodeData(deviceCode, userCode, DeviceCodeStatus.PENDING, null); DeviceCodeData data = new DeviceCodeData(deviceCode, userCode, DeviceCodeStatus.PENDING, null);
redisTemplate.opsForValue().set( redisTemplate.opsForValue().set(
DEVICE_CODE_PREFIX + deviceCode, data, EXPIRES_IN_SECONDS / 60, TimeUnit.MINUTES); DEVICE_CODE_PREFIX + deviceCode, data, PENDING_CODE_TTL_MINUTES, TimeUnit.MINUTES);
redisTemplate.opsForValue().set( redisTemplate.opsForValue().set(
USER_CODE_PREFIX + userCode, deviceCode, EXPIRES_IN_SECONDS / 60, TimeUnit.MINUTES); USER_CODE_PREFIX + userCode, deviceCode, PENDING_CODE_TTL_MINUTES, TimeUnit.MINUTES);
return new DeviceCodeResponse(deviceCode, userCode, verificationUri, EXPIRES_IN_SECONDS, POLL_INTERVAL_SECONDS); return new DeviceCodeResponse(deviceCode, userCode, verificationUri, EXPIRES_IN_SECONDS, POLL_INTERVAL_SECONDS);
} }
@ -67,60 +58,76 @@ public class DeviceAuthService {
throw new DomainBadRequestException("error.deviceAuth.userCode.invalid"); throw new DomainBadRequestException("error.deviceAuth.userCode.invalid");
} }
DeviceCodeData data = readDeviceCodeData(deviceCode); DeviceCodeData data = (DeviceCodeData) redisTemplate.opsForValue().get(DEVICE_CODE_PREFIX + deviceCode);
if (data == null) { if (data == null) {
throw new DomainBadRequestException("error.deviceAuth.deviceCode.expired"); throw new DomainBadRequestException("error.deviceAuth.deviceCode.expired");
} }
data.setStatus(DeviceCodeStatus.AUTHORIZED); switch (data.getStatus()) {
data.setUserId(userId); case PENDING -> {
redisTemplate.opsForValue().set( data.setStatus(DeviceCodeStatus.AUTHORIZED);
DEVICE_CODE_PREFIX + deviceCode, data, EXPIRES_IN_SECONDS / 60, TimeUnit.MINUTES); data.setUserId(userId);
redisTemplate.opsForValue().set(
DEVICE_CODE_PREFIX + deviceCode, data, PENDING_CODE_TTL_MINUTES, TimeUnit.MINUTES);
}
case AUTHORIZED -> {
if (!userId.equals(data.getUserId())) {
throw new DomainBadRequestException("error.deviceAuth.deviceCode.alreadyAuthorized");
}
}
case USED -> throw new DomainBadRequestException("error.deviceAuth.deviceCode.used");
}
} }
public DeviceTokenResponse pollToken(String deviceCode) { public DeviceTokenResponse pollToken(String deviceCode) {
String key = DEVICE_CODE_PREFIX + deviceCode; DeviceCodeData data = (DeviceCodeData) redisTemplate.opsForValue().get(DEVICE_CODE_PREFIX + deviceCode);
DeviceCodeData consumed = redisTemplate.execute(new SessionCallback<>() {
@Override
public DeviceCodeData execute(RedisOperations operations) {
while (true) {
operations.watch(key);
DeviceCodeData data = readDeviceCodeData(operations.opsForValue(), deviceCode);
if (data == null) { if (data == null) {
operations.unwatch(); throw new DomainBadRequestException("error.deviceAuth.deviceCode.invalid");
throw new DomainBadRequestException("error.deviceAuth.deviceCode.invalid");
}
switch (data.getStatus()) {
case PENDING -> {
operations.unwatch();
return null;
}
case USED -> {
operations.unwatch();
throw new DomainBadRequestException("error.deviceAuth.deviceCode.used");
}
case AUTHORIZED -> {
data.setStatus(DeviceCodeStatus.USED);
operations.multi();
operations.opsForValue().set(key, data, 1, TimeUnit.MINUTES);
if (operations.exec() != null) {
return data;
}
}
}
}
}
});
if (consumed == null) {
return DeviceTokenResponse.pending();
} }
String token = apiTokenService.createToken( return switch (data.getStatus()) {
consumed.getUserId(), "device-auth", "[]").rawToken(); case PENDING -> DeviceTokenResponse.pending();
return DeviceTokenResponse.success(token); case AUTHORIZED -> redeemAuthorizedDeviceCode(deviceCode, data);
case USED -> throw new DomainBadRequestException("error.deviceAuth.deviceCode.used");
};
}
private DeviceTokenResponse redeemAuthorizedDeviceCode(String deviceCode, DeviceCodeData data) {
boolean claimed = Boolean.TRUE.equals(redisTemplate.opsForValue().setIfAbsent(
DEVICE_CLAIM_PREFIX + deviceCode,
"claimed",
USED_CODE_TTL_MINUTES,
TimeUnit.MINUTES
));
if (!claimed) {
throw new DomainBadRequestException("error.deviceAuth.deviceCode.used");
}
try {
if (!StringUtils.hasText(data.getUserId())) {
throw new DomainBadRequestException("error.deviceAuth.deviceCode.invalid");
}
String token = apiTokenService.createToken(
data.getUserId(),
CLI_DEVICE_TOKEN_NAME,
CLI_DEVICE_SCOPE_JSON
).rawToken();
data.setStatus(DeviceCodeStatus.USED);
redisTemplate.opsForValue().set(
DEVICE_CODE_PREFIX + deviceCode,
data,
USED_CODE_TTL_MINUTES,
TimeUnit.MINUTES
);
redisTemplate.delete(USER_CODE_PREFIX + data.getUserCode());
return DeviceTokenResponse.success(token);
} catch (RuntimeException ex) {
redisTemplate.delete(DEVICE_CLAIM_PREFIX + deviceCode);
throw ex;
}
} }
private String generateRandomDeviceCode() { private String generateRandomDeviceCode() {
@ -137,19 +144,4 @@ public class DeviceAuthService {
} }
return code.toString(); return code.toString();
} }
private DeviceCodeData readDeviceCodeData(String deviceCode) {
return readDeviceCodeData(redisTemplate.opsForValue(), deviceCode);
}
private DeviceCodeData readDeviceCodeData(ValueOperations<String, Object> valueOperations, String deviceCode) {
Object raw = valueOperations.get(DEVICE_CODE_PREFIX + deviceCode);
if (raw == null) {
return null;
}
if (raw instanceof DeviceCodeData data) {
return data;
}
return objectMapper.convertValue(raw, DeviceCodeData.class);
}
} }

View file

@ -1,9 +1,10 @@
package com.iflytek.skillhub.auth.repository; package com.iflytek.skillhub.auth.repository;
import com.iflytek.skillhub.auth.entity.UserRoleBinding; import com.iflytek.skillhub.auth.entity.UserRoleBinding;
import java.util.Collection;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.Collection;
import java.util.List; import java.util.List;
@Repository @Repository

View file

@ -16,6 +16,9 @@ import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException; import java.io.IOException;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@ -28,13 +31,16 @@ public class ApiTokenAuthenticationFilter extends OncePerRequestFilter {
private final ApiTokenService apiTokenService; private final ApiTokenService apiTokenService;
private final UserAccountRepository userRepo; private final UserAccountRepository userRepo;
private final UserRoleBindingRepository roleBindingRepo; private final UserRoleBindingRepository roleBindingRepo;
private final ApiTokenScopeService apiTokenScopeService;
public ApiTokenAuthenticationFilter(ApiTokenService apiTokenService, public ApiTokenAuthenticationFilter(ApiTokenService apiTokenService,
UserAccountRepository userRepo, UserAccountRepository userRepo,
UserRoleBindingRepository roleBindingRepo) { UserRoleBindingRepository roleBindingRepo,
ApiTokenScopeService apiTokenScopeService) {
this.apiTokenService = apiTokenService; this.apiTokenService = apiTokenService;
this.userRepo = userRepo; this.userRepo = userRepo;
this.roleBindingRepo = roleBindingRepo; this.roleBindingRepo = roleBindingRepo;
this.apiTokenScopeService = apiTokenScopeService;
} }
@Override @Override
@ -44,20 +50,28 @@ public class ApiTokenAuthenticationFilter extends OncePerRequestFilter {
if (authHeader != null && authHeader.startsWith(BEARER_PREFIX)) { if (authHeader != null && authHeader.startsWith(BEARER_PREFIX)) {
String rawToken = authHeader.substring(BEARER_PREFIX.length()); String rawToken = authHeader.substring(BEARER_PREFIX.length());
apiTokenService.validateToken(rawToken).ifPresent(token -> { apiTokenService.validateToken(rawToken).ifPresent(token -> {
apiTokenService.touchLastUsed(token);
userRepo.findById(token.getUserId()).ifPresent(user -> { userRepo.findById(token.getUserId()).ifPresent(user -> {
if (!user.isActive()) {
return;
}
Set<String> roles = roleBindingRepo.findByUserId(user.getId()).stream() Set<String> roles = roleBindingRepo.findByUserId(user.getId()).stream()
.map(rb -> rb.getRole().getCode()) .map(rb -> rb.getRole().getCode())
.collect(Collectors.toSet()); .collect(Collectors.toSet());
Set<String> scopes = apiTokenScopeService.parseScopes(token.getScopeJson());
PlatformPrincipal principal = new PlatformPrincipal( PlatformPrincipal principal = new PlatformPrincipal(
user.getId(), user.getDisplayName(), user.getEmail(), user.getId(), user.getDisplayName(), user.getEmail(),
user.getAvatarUrl(), "api_token", roles user.getAvatarUrl(), "api_token", roles
); );
var authorities = roles.stream() List<SimpleGrantedAuthority> authorities = new ArrayList<>();
authorities.addAll(roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role)) .map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.toList(); .toList());
authorities.addAll(scopes.stream()
.map(scope -> new SimpleGrantedAuthority("SCOPE_" + scope))
.toList());
var auth = new UsernamePasswordAuthenticationToken(principal, null, authorities); var auth = new UsernamePasswordAuthenticationToken(principal, null, authorities);
SecurityContextHolder.getContext().setAuthentication(auth); SecurityContextHolder.getContext().setAuthentication(auth);
apiTokenService.touchLastUsed(token);
}); });
}); });
} }

View file

@ -0,0 +1,81 @@
package com.iflytek.skillhub.auth.token;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Set;
import java.util.stream.Collectors;
@Component
public class ApiTokenScopeFilter extends OncePerRequestFilter {
private final ApiTokenScopeService apiTokenScopeService;
private final AccessDeniedHandler accessDeniedHandler;
public ApiTokenScopeFilter(ApiTokenScopeService apiTokenScopeService,
AccessDeniedHandler accessDeniedHandler) {
this.apiTokenScopeService = apiTokenScopeService;
this.accessDeniedHandler = accessDeniedHandler;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (!isApiTokenAuthentication(authentication)) {
filterChain.doFilter(request, response);
return;
}
Set<String> tokenScopes = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.filter(authority -> authority.startsWith("SCOPE_"))
.map(authority -> authority.substring("SCOPE_".length()))
.collect(Collectors.toSet());
ApiTokenScopeService.AuthorizationDecision decision = apiTokenScopeService.authorize(
request.getMethod(),
request.getRequestURI(),
tokenScopes
);
if (decision.allowed()) {
filterChain.doFilter(request, response);
return;
}
accessDeniedHandler.handle(
request,
response,
new AccessDeniedException(decision.message())
);
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = request.getRequestURI();
return path == null || (!path.startsWith("/api/v1/") && !path.startsWith("/api/compat/"));
}
private boolean isApiTokenAuthentication(Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) {
return false;
}
Object principal = authentication.getPrincipal();
return principal instanceof PlatformPrincipal platformPrincipal
&& "api_token".equals(platformPrincipal.oauthProvider());
}
}

View file

@ -0,0 +1,132 @@
package com.iflytek.skillhub.auth.token;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service;
import org.springframework.util.AntPathMatcher;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
@Service
public class ApiTokenScopeService {
private static final TypeReference<List<String>> STRING_LIST = new TypeReference<>() {
};
private static final List<ScopeRule> UNSCOPED_ALLOWED_RULES = List.of(
ScopeRule.allow(null, "/api/v1/health"),
ScopeRule.allow(null, "/api/v1/auth/providers"),
ScopeRule.allow(null, "/api/v1/auth/me"),
ScopeRule.allow(null, "/api/v1/cli/auth/device/**"),
ScopeRule.allow(null, "/api/v1/cli/check"),
ScopeRule.allow("GET", "/api/v1/cli/whoami"),
ScopeRule.allow("GET", "/api/v1/skills"),
ScopeRule.allow("GET", "/api/v1/skills/**"),
ScopeRule.allow("GET", "/api/v1/namespaces"),
ScopeRule.allow("GET", "/api/v1/namespaces/*"),
ScopeRule.allow("GET", "/api/compat/v1/search"),
ScopeRule.allow("GET", "/api/compat/v1/resolve/**"),
ScopeRule.allow("GET", "/api/compat/v1/whoami"),
ScopeRule.allow(null, "/.well-known/**"),
ScopeRule.allow(null, "/actuator/health"),
ScopeRule.allow(null, "/v3/api-docs/**"),
ScopeRule.allow(null, "/swagger-ui/**")
);
private static final List<ScopeRule> REQUIRED_SCOPE_RULES = List.of(
ScopeRule.require(null, "/api/v1/tokens", "token:manage"),
ScopeRule.require(null, "/api/v1/tokens/**", "token:manage"),
ScopeRule.require("POST", "/api/v1/skills/*/publish", "skill:publish"),
ScopeRule.require("POST", "/api/v1/cli/publish", "skill:publish"),
ScopeRule.require("POST", "/api/compat/v1/publish", "skill:publish")
);
private final ObjectMapper objectMapper;
private final AntPathMatcher pathMatcher = new AntPathMatcher();
public ApiTokenScopeService(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
public Set<String> parseScopes(String scopeJson) {
if (scopeJson == null || scopeJson.isBlank()) {
return Set.of();
}
try {
List<String> scopes = objectMapper.readValue(scopeJson, STRING_LIST);
Set<String> normalized = new LinkedHashSet<>();
for (String scope : scopes) {
if (scope != null) {
String trimmed = scope.trim();
if (!trimmed.isEmpty()) {
normalized.add(trimmed);
}
}
}
return Set.copyOf(normalized);
} catch (Exception e) {
return Set.of();
}
}
public AuthorizationDecision authorize(String method, String path, Set<String> tokenScopes) {
if (!isApiPath(path)) {
return AuthorizationDecision.allow();
}
for (ScopeRule rule : UNSCOPED_ALLOWED_RULES) {
if (rule.matches(method, path, pathMatcher)) {
return AuthorizationDecision.allow();
}
}
for (ScopeRule rule : REQUIRED_SCOPE_RULES) {
if (rule.matches(method, path, pathMatcher)) {
if (tokenScopes.contains(rule.requiredScope())) {
return AuthorizationDecision.allow();
}
return AuthorizationDecision.missingScope(rule.requiredScope());
}
}
return AuthorizationDecision.unsupported(path);
}
private boolean isApiPath(String path) {
return path != null && (path.startsWith("/api/v1/") || path.startsWith("/api/compat/"));
}
public record AuthorizationDecision(boolean allowed, String requiredScope, String message) {
public static AuthorizationDecision allow() {
return new AuthorizationDecision(true, null, null);
}
public static AuthorizationDecision missingScope(String requiredScope) {
return new AuthorizationDecision(false, requiredScope, "Missing API token scope: " + requiredScope);
}
public static AuthorizationDecision unsupported(String path) {
return new AuthorizationDecision(false, null, "API token cannot access endpoint: " + path);
}
}
private record ScopeRule(String method, String pattern, String requiredScope) {
static ScopeRule allow(String method, String pattern) {
return new ScopeRule(method, pattern, null);
}
static ScopeRule require(String method, String pattern, String requiredScope) {
return new ScopeRule(method, pattern, requiredScope);
}
boolean matches(String requestMethod, String requestPath, AntPathMatcher matcher) {
if (method != null && !method.equalsIgnoreCase(requestMethod)) {
return false;
}
return matcher.match(pattern, requestPath);
}
}
}

View file

@ -1,21 +1,17 @@
package com.iflytek.skillhub.auth.device; package com.iflytek.skillhub.auth.device;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.auth.entity.ApiToken;
import com.iflytek.skillhub.auth.token.ApiTokenService; import com.iflytek.skillhub.auth.token.ApiTokenService;
import com.fasterxml.jackson.databind.ObjectMapper; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.redis.core.RedisOperations;
import org.springframework.data.redis.core.SessionCallback;
import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations; import org.springframework.data.redis.core.ValueOperations;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@ -31,8 +27,6 @@ class DeviceAuthServiceTest {
@Mock @Mock
private ValueOperations<String, Object> valueOperations; private ValueOperations<String, Object> valueOperations;
@Mock
private RedisOperations<String, Object> redisOperations;
@Mock @Mock
private ApiTokenService apiTokenService; private ApiTokenService apiTokenService;
@ -41,11 +35,8 @@ class DeviceAuthServiceTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
lenient().when(redisTemplate.opsForValue()).thenReturn(valueOperations); when(redisTemplate.opsForValue()).thenReturn(valueOperations);
lenient().when(redisOperations.opsForValue()).thenReturn(valueOperations); service = new DeviceAuthService(redisTemplate, apiTokenService, "https://skillhub.example.com/device");
lenient().when(redisTemplate.execute(any(SessionCallback.class)))
.thenAnswer(invocation -> invocation.<SessionCallback<?>>getArgument(0).execute(redisOperations));
service = new DeviceAuthService(redisTemplate, apiTokenService, "https://skillhub.example.com/device", new ObjectMapper());
} }
@Test @Test
@ -91,6 +82,41 @@ class DeviceAuthServiceTest {
assertThat(response.accessToken()).isNull(); assertThat(response.accessToken()).isNull();
} }
@Test
void pollToken_returns_access_token_when_authorized() {
// Given
DeviceCodeData data = new DeviceCodeData("device123", "ABCD-1234", DeviceCodeStatus.AUTHORIZED, "42");
when(valueOperations.get("device:code:device123")).thenReturn(data);
when(valueOperations.setIfAbsent("device:claim:device123", "claimed", 1L, TimeUnit.MINUTES)).thenReturn(true);
when(apiTokenService.createToken("42", "CLI Device Flow", "[\"skill:read\",\"skill:publish\"]"))
.thenReturn(new ApiTokenService.TokenCreateResult("sk_cli_token", mock(ApiToken.class)));
// When
DeviceTokenResponse response = service.pollToken("device123");
// Then
assertThat(response.accessToken()).isEqualTo("sk_cli_token");
assertThat(response.tokenType()).isEqualTo("Bearer");
assertThat(response.error()).isNull();
assertThat(data.getStatus()).isEqualTo(DeviceCodeStatus.USED);
verify(valueOperations).set("device:code:device123", data, 1L, TimeUnit.MINUTES);
verify(redisTemplate).delete("device:usercode:ABCD-1234");
}
@Test
void pollToken_rejects_second_exchange_attempt() {
// Given
DeviceCodeData data = new DeviceCodeData("device123", "ABCD-1234", DeviceCodeStatus.AUTHORIZED, "42");
when(valueOperations.get("device:code:device123")).thenReturn(data);
when(valueOperations.setIfAbsent("device:claim:device123", "claimed", 1L, TimeUnit.MINUTES)).thenReturn(false);
// When / Then
assertThatThrownBy(() -> service.pollToken("device123"))
.isInstanceOf(DomainBadRequestException.class)
.hasMessageContaining("error.deviceAuth.deviceCode.used");
verify(apiTokenService, never()).createToken(anyString(), anyString(), anyString());
}
@Test @Test
void pollToken_returns_error_when_expired() { void pollToken_returns_error_when_expired() {
// Given // Given
@ -119,62 +145,15 @@ class DeviceAuthServiceTest {
} }
@Test @Test
void authorizeDeviceCode_accepts_linked_hash_map_from_redis_serializer() { void authorizeDeviceCode_rejects_different_user_after_authorization() {
Map<String, Object> redisValue = new HashMap<>(); // Given
redisValue.put("deviceCode", "device123");
redisValue.put("userCode", "ABCD-1234");
redisValue.put("status", "PENDING");
redisValue.put("userId", null);
when(valueOperations.get("device:usercode:ABCD-1234")).thenReturn("device123");
when(valueOperations.get("device:code:device123")).thenReturn(redisValue);
service.authorizeDeviceCode("ABCD-1234", "42");
ArgumentCaptor<DeviceCodeData> captor = ArgumentCaptor.forClass(DeviceCodeData.class);
verify(valueOperations).set(eq("device:code:device123"), captor.capture(), eq(15L), eq(TimeUnit.MINUTES));
assertThat(captor.getValue().getStatus()).isEqualTo(DeviceCodeStatus.AUTHORIZED);
assertThat(captor.getValue().getUserId()).isEqualTo("42");
}
@Test
void pollToken_accepts_linked_hash_map_from_redis_serializer() {
Map<String, Object> redisValue = new HashMap<>();
redisValue.put("deviceCode", "device123");
redisValue.put("userCode", "ABCD-1234");
redisValue.put("status", "AUTHORIZED");
redisValue.put("userId", "42");
when(valueOperations.get("device:code:device123")).thenReturn(redisValue);
when(redisOperations.exec()).thenReturn(java.util.List.of("OK"));
when(apiTokenService.createToken("42", "device-auth", "[]"))
.thenReturn(new ApiTokenService.TokenCreateResult("sk_device_token", null));
DeviceTokenResponse response = service.pollToken("device123");
assertThat(response.error()).isNull();
assertThat(response.accessToken()).isEqualTo("sk_device_token");
assertThat(response.tokenType()).isEqualTo("Bearer");
verify(redisOperations).watch("device:code:device123");
verify(redisOperations).multi();
verify(valueOperations).set(eq("device:code:device123"), any(DeviceCodeData.class), eq(1L), eq(TimeUnit.MINUTES));
verify(redisOperations).exec();
}
@Test
void pollToken_returns_access_token_when_authorized() {
DeviceCodeData data = new DeviceCodeData("device123", "ABCD-1234", DeviceCodeStatus.AUTHORIZED, "42"); DeviceCodeData data = new DeviceCodeData("device123", "ABCD-1234", DeviceCodeStatus.AUTHORIZED, "42");
when(valueOperations.get("device:usercode:ABCD-1234")).thenReturn("device123");
when(valueOperations.get("device:code:device123")).thenReturn(data); when(valueOperations.get("device:code:device123")).thenReturn(data);
when(redisOperations.exec()).thenReturn(java.util.List.of("OK"));
when(apiTokenService.createToken("42", "device-auth", "[]"))
.thenReturn(new ApiTokenService.TokenCreateResult("sk_device_token", null));
DeviceTokenResponse response = service.pollToken("device123"); // When / Then
assertThatThrownBy(() -> service.authorizeDeviceCode("ABCD-1234", "99"))
assertThat(response.error()).isNull(); .isInstanceOf(DomainBadRequestException.class)
assertThat(response.accessToken()).isEqualTo("sk_device_token"); .hasMessageContaining("error.deviceAuth.deviceCode.alreadyAuthorized");
assertThat(response.tokenType()).isEqualTo("Bearer");
verify(redisOperations).watch("device:code:device123");
verify(redisOperations).multi();
verify(valueOperations).set(eq("device:code:device123"), eq(data), eq(1L), eq(TimeUnit.MINUTES));
verify(redisOperations).exec();
} }
} }

View file

@ -0,0 +1,95 @@
package com.iflytek.skillhub.auth.token;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.auth.entity.ApiToken;
import com.iflytek.skillhub.auth.entity.Role;
import com.iflytek.skillhub.auth.entity.UserRoleBinding;
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.domain.user.UserStatus;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockFilterChain;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.core.context.SecurityContextHolder;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
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 ApiTokenAuthenticationFilterTest {
private final ApiTokenService apiTokenService = mock(ApiTokenService.class);
private final UserAccountRepository userAccountRepository = mock(UserAccountRepository.class);
private final UserRoleBindingRepository roleBindingRepository = mock(UserRoleBindingRepository.class);
private final ApiTokenScopeService scopeService = new ApiTokenScopeService(new ObjectMapper());
private final ApiTokenAuthenticationFilter filter = new ApiTokenAuthenticationFilter(
apiTokenService,
userAccountRepository,
roleBindingRepository,
scopeService
);
@AfterEach
void clearSecurityContext() {
SecurityContextHolder.clearContext();
}
@Test
void shouldPopulateRoleAndScopeAuthoritiesForActiveUser() throws Exception {
ApiToken token = new ApiToken("user-1", "cli", "sk_test", "hash", "[\"skill:publish\",\"token:manage\"]");
UserAccount user = new UserAccount("user-1", "Alice", "alice@example.com", "");
UserRoleBinding binding = mock(UserRoleBinding.class);
Role role = mock(Role.class);
when(apiTokenService.validateToken("raw-token")).thenReturn(Optional.of(token));
when(userAccountRepository.findById("user-1")).thenReturn(Optional.of(user));
when(roleBindingRepository.findByUserId("user-1")).thenReturn(List.of(binding));
when(binding.getRole()).thenReturn(role);
when(role.getCode()).thenReturn("SKILL_ADMIN");
MockHttpServletRequest request = new MockHttpServletRequest();
request.setRequestURI("/api/v1/cli/whoami");
request.addHeader("Authorization", "Bearer raw-token");
filter.doFilter(request, new MockHttpServletResponse(), new MockFilterChain());
var authentication = SecurityContextHolder.getContext().getAuthentication();
assertNotNull(authentication);
assertTrue(authentication.getAuthorities().stream()
.anyMatch(authority -> authority.getAuthority().equals("ROLE_SKILL_ADMIN")));
assertTrue(authentication.getAuthorities().stream()
.anyMatch(authority -> authority.getAuthority().equals("SCOPE_skill:publish")));
assertTrue(authentication.getAuthorities().stream()
.anyMatch(authority -> authority.getAuthority().equals("SCOPE_token:manage")));
verify(apiTokenService).touchLastUsed(token);
}
@Test
void shouldRejectDisabledUsers() throws Exception {
ApiToken token = new ApiToken("user-2", "cli", "sk_test", "hash", "[\"skill:publish\"]");
UserAccount user = new UserAccount("user-2", "Bob", "bob@example.com", "");
user.setStatus(UserStatus.DISABLED);
when(apiTokenService.validateToken("raw-token")).thenReturn(Optional.of(token));
when(userAccountRepository.findById("user-2")).thenReturn(Optional.of(user));
MockHttpServletRequest request = new MockHttpServletRequest();
request.setRequestURI("/api/v1/cli/publish");
request.addHeader("Authorization", "Bearer raw-token");
filter.doFilter(request, new MockHttpServletResponse(), new MockFilterChain());
assertNull(SecurityContextHolder.getContext().getAuthentication());
verify(apiTokenService, never()).touchLastUsed(token);
}
}

View file

@ -0,0 +1,102 @@
package com.iflytek.skillhub.auth.token;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import jakarta.servlet.FilterChain;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.access.AccessDeniedHandler;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
class ApiTokenScopeFilterTest {
private final ApiTokenScopeService scopeService = new ApiTokenScopeService(new ObjectMapper());
@AfterEach
void clearSecurityContext() {
SecurityContextHolder.clearContext();
}
@Test
void shouldDenyApiTokenWithoutRequiredScope() throws Exception {
AccessDeniedHandler handler = (request, response, accessDeniedException) -> {
response.sendError(HttpServletResponse.SC_FORBIDDEN, accessDeniedException.getMessage());
};
ApiTokenScopeFilter filter = new ApiTokenScopeFilter(scopeService, handler);
PlatformPrincipal principal = new PlatformPrincipal(
"user-1",
"Alice",
"alice@example.com",
"",
"api_token",
Set.of("SKILL_ADMIN")
);
var authentication = new UsernamePasswordAuthenticationToken(
principal,
null,
List.of(
new SimpleGrantedAuthority("ROLE_SKILL_ADMIN"),
new SimpleGrantedAuthority("SCOPE_skill:read")
)
);
SecurityContextHolder.getContext().setAuthentication(authentication);
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/cli/publish");
MockHttpServletResponse response = new MockHttpServletResponse();
FilterChain chain = mock(FilterChain.class);
filter.doFilter(request, response, chain);
assertEquals(HttpServletResponse.SC_FORBIDDEN, response.getStatus());
assertTrue(response.getErrorMessage().contains("Missing API token scope: skill:publish"));
verify(chain, never()).doFilter(request, response);
}
@Test
void shouldAllowSessionAuthRequestsWithoutScopeChecks() throws Exception {
AccessDeniedHandler handler = mock(AccessDeniedHandler.class);
ApiTokenScopeFilter filter = new ApiTokenScopeFilter(scopeService, handler);
PlatformPrincipal principal = new PlatformPrincipal(
"user-2",
"Carol",
"carol@example.com",
"",
"github",
Set.of("SUPER_ADMIN")
);
var authentication = new UsernamePasswordAuthenticationToken(
principal,
null,
List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN"))
);
SecurityContextHolder.getContext().setAuthentication(authentication);
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/admin/users/user-2/status");
MockHttpServletResponse response = new MockHttpServletResponse();
FilterChain chain = mock(FilterChain.class);
filter.doFilter(request, response, chain);
verify(chain).doFilter(request, response);
verify(handler, never()).handle(eq(request), eq(response), any());
}
}

View file

@ -0,0 +1,77 @@
package com.iflytek.skillhub.auth.token;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ApiTokenScopeServiceTest {
private final ApiTokenScopeService scopeService = new ApiTokenScopeService(new ObjectMapper());
@Test
void parseScopesShouldNormalizeJsonArray() {
Set<String> scopes = scopeService.parseScopes("[\"skill:read\", \"skill:publish\", \"skill:read\", \" \"]");
assertEquals(Set.of("skill:read", "skill:publish"), scopes);
}
@Test
void authorizeShouldAllowCliWhoamiWithoutScope() {
ApiTokenScopeService.AuthorizationDecision decision = scopeService.authorize(
"GET",
"/api/v1/cli/whoami",
Set.of()
);
assertTrue(decision.allowed());
}
@Test
void authorizeShouldRequirePublishScopeForPortalPublish() {
ApiTokenScopeService.AuthorizationDecision denied = scopeService.authorize(
"POST",
"/api/v1/skills/team-a/publish",
Set.of("skill:read")
);
assertFalse(denied.allowed());
assertEquals("skill:publish", denied.requiredScope());
ApiTokenScopeService.AuthorizationDecision allowed = scopeService.authorize(
"POST",
"/api/v1/skills/team-a/publish",
Set.of("skill:publish")
);
assertTrue(allowed.allowed());
}
@Test
void authorizeShouldRequireTokenManageScopeForTokenEndpoints() {
ApiTokenScopeService.AuthorizationDecision decision = scopeService.authorize(
"GET",
"/api/v1/tokens",
Set.of("skill:publish")
);
assertFalse(decision.allowed());
assertEquals("token:manage", decision.requiredScope());
}
@Test
void authorizeShouldDenyUnsupportedAuthenticatedEndpoints() {
ApiTokenScopeService.AuthorizationDecision decision = scopeService.authorize(
"GET",
"/api/v1/me/skills",
Set.of("skill:read", "skill:publish")
);
assertFalse(decision.allowed());
assertEquals("API token cannot access endpoint: /api/v1/me/skills", decision.message());
}
}

View file

@ -9,15 +9,14 @@ import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
import com.iflytek.skillhub.domain.skill.*; import com.iflytek.skillhub.domain.skill.*;
import jakarta.persistence.EntityManager;
import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ConcurrentModificationException; import java.util.ConcurrentModificationException;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Set; import java.util.Set;
@Service @Service
@ -30,7 +29,6 @@ public class PromotionService {
private final NamespaceRepository namespaceRepository; private final NamespaceRepository namespaceRepository;
private final ReviewPermissionChecker permissionChecker; private final ReviewPermissionChecker permissionChecker;
private final ApplicationEventPublisher eventPublisher; private final ApplicationEventPublisher eventPublisher;
private final EntityManager entityManager;
public PromotionService(PromotionRequestRepository promotionRequestRepository, public PromotionService(PromotionRequestRepository promotionRequestRepository,
SkillRepository skillRepository, SkillRepository skillRepository,
@ -38,8 +36,7 @@ public class PromotionService {
SkillFileRepository skillFileRepository, SkillFileRepository skillFileRepository,
NamespaceRepository namespaceRepository, NamespaceRepository namespaceRepository,
ReviewPermissionChecker permissionChecker, ReviewPermissionChecker permissionChecker,
ApplicationEventPublisher eventPublisher, ApplicationEventPublisher eventPublisher) {
EntityManager entityManager) {
this.promotionRequestRepository = promotionRequestRepository; this.promotionRequestRepository = promotionRequestRepository;
this.skillRepository = skillRepository; this.skillRepository = skillRepository;
this.skillVersionRepository = skillVersionRepository; this.skillVersionRepository = skillVersionRepository;
@ -47,13 +44,12 @@ public class PromotionService {
this.namespaceRepository = namespaceRepository; this.namespaceRepository = namespaceRepository;
this.permissionChecker = permissionChecker; this.permissionChecker = permissionChecker;
this.eventPublisher = eventPublisher; this.eventPublisher = eventPublisher;
this.entityManager = entityManager;
} }
@Transactional @Transactional
public PromotionRequest submitPromotion(Long sourceSkillId, Long sourceVersionId, public PromotionRequest submitPromotion(Long sourceSkillId, Long sourceVersionId,
Long targetNamespaceId, String userId, Long targetNamespaceId, String userId,
java.util.Map<Long, NamespaceRole> userNamespaceRoles, Map<Long, NamespaceRole> userNamespaceRoles,
Set<String> platformRoles) { Set<String> platformRoles) {
Skill sourceSkill = skillRepository.findById(sourceSkillId) Skill sourceSkill = skillRepository.findById(sourceSkillId)
.orElseThrow(() -> new DomainNotFoundException("skill.not_found", sourceSkillId)); .orElseThrow(() -> new DomainNotFoundException("skill.not_found", sourceSkillId));
@ -89,6 +85,44 @@ public class PromotionService {
return promotionRequestRepository.save(request); return promotionRequestRepository.save(request);
} }
@Transactional
public PromotionRequest submitPromotion(Long sourceSkillId, Long sourceVersionId,
Long targetNamespaceId, String userId,
Map<Long, NamespaceRole> userNamespaceRoles) {
Skill sourceSkill = skillRepository.findById(sourceSkillId)
.orElseThrow(() -> new DomainNotFoundException("skill.not_found", sourceSkillId));
SkillVersion sourceVersion = skillVersionRepository.findById(sourceVersionId)
.orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", sourceVersionId));
if (!sourceVersion.getSkillId().equals(sourceSkillId)) {
throw new DomainBadRequestException("promotion.version_skill_mismatch", sourceVersionId, sourceSkillId);
}
if (sourceVersion.getStatus() != SkillVersionStatus.PUBLISHED) {
throw new DomainBadRequestException("promotion.version_not_published", sourceVersionId);
}
if (!permissionChecker.canSubmitPromotion(sourceSkill, userId, userNamespaceRoles)) {
throw new DomainForbiddenException("promotion.submit.no_permission");
}
Namespace targetNamespace = namespaceRepository.findById(targetNamespaceId)
.orElseThrow(() -> new DomainNotFoundException("namespace.not_found", targetNamespaceId));
if (targetNamespace.getType() != NamespaceType.GLOBAL) {
throw new DomainBadRequestException("promotion.target_not_global", targetNamespaceId);
}
promotionRequestRepository.findBySourceVersionIdAndStatus(sourceVersionId, ReviewTaskStatus.PENDING)
.ifPresent(existing -> {
throw new DomainBadRequestException("promotion.duplicate_pending", sourceVersionId);
});
PromotionRequest request = new PromotionRequest(sourceSkillId, sourceVersionId, targetNamespaceId, userId);
return promotionRequestRepository.save(request);
}
@Transactional @Transactional
public PromotionRequest approvePromotion(Long promotionId, String reviewerId, public PromotionRequest approvePromotion(Long promotionId, String reviewerId,
String comment, Set<String> platformRoles) { String comment, Set<String> platformRoles) {
@ -108,11 +142,6 @@ public class PromotionService {
if (updated == 0) { if (updated == 0) {
throw new ConcurrentModificationException("Promotion request was modified concurrently"); throw new ConcurrentModificationException("Promotion request was modified concurrently");
} }
entityManager.detach(request);
request.setStatus(ReviewTaskStatus.APPROVED);
request.setReviewedBy(reviewerId);
request.setReviewComment(comment);
request.setReviewedAt(Instant.now());
Skill sourceSkill = skillRepository.findById(request.getSourceSkillId()) Skill sourceSkill = skillRepository.findById(request.getSourceSkillId())
.orElseThrow(() -> new DomainNotFoundException("skill.not_found", request.getSourceSkillId())); .orElseThrow(() -> new DomainNotFoundException("skill.not_found", request.getSourceSkillId()));
@ -155,12 +184,9 @@ public class PromotionService {
.toList(); .toList();
skillFileRepository.saveAll(copiedFiles); skillFileRepository.saveAll(copiedFiles);
int targetUpdated = promotionRequestRepository.updateStatusWithVersion( // Update promotion request with target skill id
promotionId, ReviewTaskStatus.APPROVED, reviewerId, comment, newSkill.getId(), request.getVersion() + 1);
if (targetUpdated == 0) {
throw new ConcurrentModificationException("Promotion request target skill was modified concurrently");
}
request.setTargetSkillId(newSkill.getId()); request.setTargetSkillId(newSkill.getId());
promotionRequestRepository.save(request);
eventPublisher.publishEvent(new SkillPublishedEvent( eventPublisher.publishEvent(new SkillPublishedEvent(
newSkill.getId(), newVersion.getId(), reviewerId)); newSkill.getId(), newVersion.getId(), reviewerId));
@ -187,12 +213,8 @@ public class PromotionService {
if (updated == 0) { if (updated == 0) {
throw new ConcurrentModificationException("Promotion request was modified concurrently"); throw new ConcurrentModificationException("Promotion request was modified concurrently");
} }
entityManager.detach(request);
request.setStatus(ReviewTaskStatus.REJECTED); return promotionRequestRepository.findById(promotionId).orElse(request);
request.setReviewedBy(reviewerId);
request.setReviewComment(comment);
request.setReviewedAt(Instant.now());
return request;
} }
public boolean canViewPromotion(PromotionRequest request, String userId, Set<String> platformRoles) { public boolean canViewPromotion(PromotionRequest request, String userId, Set<String> platformRoles) {

View file

@ -11,27 +11,22 @@ import java.util.Set;
@Component @Component
public class ReviewPermissionChecker { public class ReviewPermissionChecker {
/** public boolean canSubmitReview(Long namespaceId,
* Check if a user can review a ReviewTask. Map<Long, NamespaceRole> userNamespaceRoles) {
* NamespaceRole role = userNamespaceRoles.get(namespaceId);
* @param task the review task return role == NamespaceRole.OWNER
* @param userId the reviewer's user ID || role == NamespaceRole.ADMIN
* @param namespaceType the type of the namespace || role == NamespaceRole.MEMBER;
* @param userNamespaceRoles user's roles keyed by namespace ID }
* @param platformRoles user's platform-level roles
* @return true if the user is allowed to review
*/
public boolean canReview(ReviewTask task, public boolean canReview(ReviewTask task,
String userId, String userId,
NamespaceType namespaceType, NamespaceType namespaceType,
Map<Long, NamespaceRole> userNamespaceRoles, Map<Long, NamespaceRole> userNamespaceRoles,
Set<String> platformRoles) { Set<String> platformRoles) {
// Admins can review their own submissions
if (task.getSubmittedBy().equals(userId)) { if (task.getSubmittedBy().equals(userId)) {
return platformRoles.contains("SKILL_ADMIN") return false;
|| platformRoles.contains("SUPER_ADMIN");
} }
return canReviewNamespace(task.getNamespaceId(), namespaceType, userNamespaceRoles, platformRoles); return canReviewNamespace(task.getNamespaceId(), namespaceType, userNamespaceRoles, platformRoles);
} }
@ -42,9 +37,7 @@ public class ReviewPermissionChecker {
if (skill.getOwnerId().equals(userId)) { if (skill.getOwnerId().equals(userId)) {
return true; return true;
} }
if (hasPlatformReviewRole(platformRoles)) {
if (platformRoles.contains("SKILL_ADMIN")
|| platformRoles.contains("SUPER_ADMIN")) {
return true; return true;
} }
@ -67,23 +60,45 @@ public class ReviewPermissionChecker {
NamespaceType namespaceType, NamespaceType namespaceType,
Map<Long, NamespaceRole> userNamespaceRoles, Map<Long, NamespaceRole> userNamespaceRoles,
Set<String> platformRoles) { Set<String> platformRoles) {
if (platformRoles.contains("SKILL_ADMIN") if (hasPlatformReviewRole(platformRoles)) {
|| platformRoles.contains("SUPER_ADMIN")) {
return true; return true;
} }
if (namespaceType == NamespaceType.GLOBAL) { if (namespaceType == NamespaceType.GLOBAL) {
return false; return false;
} }
NamespaceRole role = userNamespaceRoles.get(namespaceId); NamespaceRole role = userNamespaceRoles.get(namespaceId);
return role == NamespaceRole.ADMIN || role == NamespaceRole.OWNER; return role == NamespaceRole.OWNER || role == NamespaceRole.ADMIN;
}
public boolean canManageNamespaceReviews(Long namespaceId,
NamespaceType namespaceType,
Map<Long, NamespaceRole> userNamespaceRoles,
Set<String> platformRoles) {
return canReviewNamespace(namespaceId, namespaceType, userNamespaceRoles, platformRoles);
}
public boolean canReadReview(ReviewTask task,
String userId,
NamespaceType namespaceType,
Map<Long, NamespaceRole> userNamespaceRoles,
Set<String> platformRoles) {
return canViewReview(task, userId, namespaceType, userNamespaceRoles, platformRoles);
}
public boolean canSubmitPromotion(Skill sourceSkill,
String userId,
Map<Long, NamespaceRole> userNamespaceRoles,
Set<String> platformRoles) {
return canSubmitForReview(sourceSkill, userId, userNamespaceRoles, platformRoles);
}
public boolean canSubmitPromotion(Skill sourceSkill,
String userId,
Map<Long, NamespaceRole> userNamespaceRoles) {
return canSubmitPromotion(sourceSkill, userId, userNamespaceRoles, Set.of());
} }
/**
* Check if a user can review a PromotionRequest.
* Only SKILL_ADMIN or SUPER_ADMIN, and not own.
*/
public boolean canReviewPromotion( public boolean canReviewPromotion(
PromotionRequest request, PromotionRequest request,
String userId, String userId,
@ -91,15 +106,7 @@ public class ReviewPermissionChecker {
if (request.getSubmittedBy().equals(userId)) { if (request.getSubmittedBy().equals(userId)) {
return false; return false;
} }
return platformRoles.contains("SKILL_ADMIN") return hasPlatformReviewRole(platformRoles);
|| platformRoles.contains("SUPER_ADMIN");
}
public boolean canSubmitPromotion(Skill skill,
String userId,
Map<Long, NamespaceRole> userNamespaceRoles,
Set<String> platformRoles) {
return canSubmitForReview(skill, userId, userNamespaceRoles, platformRoles);
} }
public boolean canViewPromotion(PromotionRequest request, public boolean canViewPromotion(PromotionRequest request,
@ -110,4 +117,19 @@ public class ReviewPermissionChecker {
} }
return canReviewPromotion(request, userId, platformRoles); return canReviewPromotion(request, userId, platformRoles);
} }
public boolean canListPendingPromotions(Set<String> platformRoles) {
return hasPlatformReviewRole(platformRoles);
}
public boolean canReadPromotion(PromotionRequest request,
String userId,
Set<String> platformRoles) {
return canViewPromotion(request, userId, platformRoles);
}
private boolean hasPlatformReviewRole(Set<String> platformRoles) {
return platformRoles.contains("SKILL_ADMIN")
|| platformRoles.contains("SUPER_ADMIN");
}
} }

View file

@ -1,5 +1,6 @@
package com.iflytek.skillhub.domain.review; package com.iflytek.skillhub.domain.review;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.namespace.NamespaceRole;
@ -12,13 +13,12 @@ import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillVersion; import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.skill.SkillVersionStatus; import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
import jakarta.persistence.EntityManager; import com.iflytek.skillhub.domain.skill.metadata.SkillMetadata;
import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.ApplicationEventPublisher;
import org.springframework.dao.DataIntegrityViolationException; import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ConcurrentModificationException; import java.util.ConcurrentModificationException;
import java.util.Map; import java.util.Map;
@ -33,7 +33,7 @@ public class ReviewService {
private final NamespaceRepository namespaceRepository; private final NamespaceRepository namespaceRepository;
private final ReviewPermissionChecker permissionChecker; private final ReviewPermissionChecker permissionChecker;
private final ApplicationEventPublisher eventPublisher; private final ApplicationEventPublisher eventPublisher;
private final EntityManager entityManager; private final ObjectMapper objectMapper;
public ReviewService(ReviewTaskRepository reviewTaskRepository, public ReviewService(ReviewTaskRepository reviewTaskRepository,
SkillVersionRepository skillVersionRepository, SkillVersionRepository skillVersionRepository,
@ -41,14 +41,14 @@ public class ReviewService {
NamespaceRepository namespaceRepository, NamespaceRepository namespaceRepository,
ReviewPermissionChecker permissionChecker, ReviewPermissionChecker permissionChecker,
ApplicationEventPublisher eventPublisher, ApplicationEventPublisher eventPublisher,
EntityManager entityManager) { ObjectMapper objectMapper) {
this.reviewTaskRepository = reviewTaskRepository; this.reviewTaskRepository = reviewTaskRepository;
this.skillVersionRepository = skillVersionRepository; this.skillVersionRepository = skillVersionRepository;
this.skillRepository = skillRepository; this.skillRepository = skillRepository;
this.namespaceRepository = namespaceRepository; this.namespaceRepository = namespaceRepository;
this.permissionChecker = permissionChecker; this.permissionChecker = permissionChecker;
this.eventPublisher = eventPublisher; this.eventPublisher = eventPublisher;
this.entityManager = entityManager; this.objectMapper = objectMapper;
} }
@Transactional @Transactional
@ -58,6 +58,7 @@ public class ReviewService {
Set<String> platformRoles) { Set<String> platformRoles) {
SkillVersion skillVersion = skillVersionRepository.findById(skillVersionId) SkillVersion skillVersion = skillVersionRepository.findById(skillVersionId)
.orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", skillVersionId)); .orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", skillVersionId));
Skill skill = skillRepository.findById(skillVersion.getSkillId()) Skill skill = skillRepository.findById(skillVersion.getSkillId())
.orElseThrow(() -> new DomainNotFoundException("skill.not_found", skillVersion.getSkillId())); .orElseThrow(() -> new DomainNotFoundException("skill.not_found", skillVersion.getSkillId()));
@ -80,6 +81,35 @@ public class ReviewService {
} }
} }
@Transactional
public ReviewTask submitReview(Long skillVersionId,
String userId,
Map<Long, NamespaceRole> userNamespaceRoles) {
SkillVersion skillVersion = skillVersionRepository.findById(skillVersionId)
.orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", skillVersionId));
Skill skill = skillRepository.findById(skillVersion.getSkillId())
.orElseThrow(() -> new DomainNotFoundException("skill.not_found", skillVersion.getSkillId()));
if (skillVersion.getStatus() != SkillVersionStatus.DRAFT) {
throw new DomainBadRequestException("review.submit.not_draft", skillVersionId);
}
if (!permissionChecker.canSubmitReview(skill.getNamespaceId(), userNamespaceRoles)) {
throw new DomainForbiddenException("review.submit.no_permission");
}
skillVersion.setStatus(SkillVersionStatus.PENDING_REVIEW);
skillVersionRepository.save(skillVersion);
ReviewTask task = new ReviewTask(skillVersionId, skill.getNamespaceId(), userId);
try {
return reviewTaskRepository.save(task);
} catch (DataIntegrityViolationException e) {
throw new DomainBadRequestException("review.submit.duplicate", skillVersionId);
}
}
@Transactional @Transactional
public ReviewTask approveReview(Long reviewTaskId, String reviewerId, String comment, public ReviewTask approveReview(Long reviewTaskId, String reviewerId, String comment,
Map<Long, NamespaceRole> userNamespaceRoles, Map<Long, NamespaceRole> userNamespaceRoles,
@ -104,11 +134,6 @@ public class ReviewService {
if (updated == 0) { if (updated == 0) {
throw new ConcurrentModificationException("Review task was modified concurrently"); throw new ConcurrentModificationException("Review task was modified concurrently");
} }
entityManager.detach(task);
task.setStatus(ReviewTaskStatus.APPROVED);
task.setReviewedBy(reviewerId);
task.setReviewComment(comment);
task.setReviewedAt(Instant.now());
SkillVersion skillVersion = skillVersionRepository.findById(task.getSkillVersionId()) SkillVersion skillVersion = skillVersionRepository.findById(task.getSkillVersionId())
.orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", task.getSkillVersionId())); .orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", task.getSkillVersionId()));
@ -119,12 +144,15 @@ public class ReviewService {
Skill skill = skillRepository.findById(skillVersion.getSkillId()) Skill skill = skillRepository.findById(skillVersion.getSkillId())
.orElseThrow(() -> new DomainNotFoundException("skill.not_found", skillVersion.getSkillId())); .orElseThrow(() -> new DomainNotFoundException("skill.not_found", skillVersion.getSkillId()));
skill.setLatestVersionId(skillVersion.getId()); skill.setLatestVersionId(skillVersion.getId());
applyPublishedMetadata(skill, skillVersion);
skill.setUpdatedBy(reviewerId);
skillRepository.save(skill); skillRepository.save(skill);
eventPublisher.publishEvent(new SkillPublishedEvent( eventPublisher.publishEvent(new SkillPublishedEvent(
skill.getId(), skillVersion.getId(), reviewerId)); skill.getId(), skillVersion.getId(), reviewerId));
return task; // Reload to return updated state
return reviewTaskRepository.findById(reviewTaskId).orElse(task);
} }
@Transactional @Transactional
@ -151,18 +179,13 @@ public class ReviewService {
if (updated == 0) { if (updated == 0) {
throw new ConcurrentModificationException("Review task was modified concurrently"); throw new ConcurrentModificationException("Review task was modified concurrently");
} }
entityManager.detach(task);
task.setStatus(ReviewTaskStatus.REJECTED);
task.setReviewedBy(reviewerId);
task.setReviewComment(comment);
task.setReviewedAt(Instant.now());
SkillVersion skillVersion = skillVersionRepository.findById(task.getSkillVersionId()) SkillVersion skillVersion = skillVersionRepository.findById(task.getSkillVersionId())
.orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", task.getSkillVersionId())); .orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", task.getSkillVersionId()));
skillVersion.setStatus(SkillVersionStatus.REJECTED); skillVersion.setStatus(SkillVersionStatus.REJECTED);
skillVersionRepository.save(skillVersion); skillVersionRepository.save(skillVersion);
return task; return reviewTaskRepository.findById(reviewTaskId).orElse(task);
} }
@Transactional @Transactional
@ -198,4 +221,19 @@ public class ReviewService {
Set<String> platformRoles) { Set<String> platformRoles) {
return permissionChecker.canViewReview(task, userId, namespaceType, userNamespaceRoles, platformRoles); return permissionChecker.canViewReview(task, userId, namespaceType, userNamespaceRoles, platformRoles);
} }
private void applyPublishedMetadata(Skill skill, SkillVersion skillVersion) {
String metadataJson = skillVersion.getParsedMetadataJson();
if (metadataJson == null || metadataJson.isBlank()) {
return;
}
try {
SkillMetadata metadata = objectMapper.readValue(metadataJson, SkillMetadata.class);
skill.setDisplayName(metadata.name());
skill.setSummary(metadata.description());
} catch (Exception e) {
throw new IllegalStateException("Failed to deserialize skill metadata", e);
}
}
} }

View file

@ -57,6 +57,10 @@ public class SkillTagService {
return tags; return tags;
} }
public List<SkillTag> listTags(String namespaceSlug, String skillSlug) {
return listTags(namespaceSlug, skillSlug, null, java.util.Map.of());
}
@Transactional @Transactional
public SkillTag createOrMoveTag( public SkillTag createOrMoveTag(
String namespaceSlug, String namespaceSlug,

View file

@ -0,0 +1,56 @@
package com.iflytek.skillhub.domain.skill.validation;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Set;
public final class SkillPackagePolicy {
public static final int MAX_FILE_COUNT = 100;
public static final long MAX_SINGLE_FILE_SIZE = 1024 * 1024; // 1MB
public static final long MAX_TOTAL_PACKAGE_SIZE = 10 * 1024 * 1024; // 10MB
public static final String SKILL_MD_PATH = "SKILL.md";
public static final Set<String> ALLOWED_EXTENSIONS = Set.of(
".md", ".txt", ".json", ".yaml", ".yml",
".js", ".ts", ".py", ".sh",
".png", ".jpg", ".svg"
);
private SkillPackagePolicy() {
}
public static String normalizeEntryPath(String rawPath) {
if (rawPath == null) {
throw new IllegalArgumentException("Package entry path is missing");
}
String sanitized = rawPath.replace('\\', '/').trim();
if (sanitized.isEmpty()) {
throw new IllegalArgumentException("Package entry path is empty");
}
if (sanitized.startsWith("/") || sanitized.startsWith("\\")) {
throw new IllegalArgumentException("Package entry path must be relative: " + rawPath);
}
if (sanitized.contains(":")) {
throw new IllegalArgumentException("Package entry path contains an invalid drive or scheme prefix: " + rawPath);
}
Path normalized = Paths.get(sanitized).normalize();
String canonical = normalized.toString().replace('\\', '/');
if (normalized.isAbsolute() || canonical.isBlank()) {
throw new IllegalArgumentException("Package entry path is invalid: " + rawPath);
}
if (canonical.equals(".") || canonical.equals("..") || canonical.startsWith("../")) {
throw new IllegalArgumentException("Package entry path escapes package root: " + rawPath);
}
if (!sanitized.equals(canonical)) {
throw new IllegalArgumentException("Package entry path must be normalized: " + rawPath);
}
return canonical;
}
public static boolean hasAllowedExtension(String path) {
return ALLOWED_EXTENSIONS.stream().anyMatch(path::endsWith);
}
}

View file

@ -3,8 +3,6 @@ package com.iflytek.skillhub.domain.skill.validation;
import com.iflytek.skillhub.domain.shared.exception.LocalizedDomainException; import com.iflytek.skillhub.domain.shared.exception.LocalizedDomainException;
import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser; import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
@ -12,13 +10,6 @@ import java.util.Set;
public class SkillPackageValidator { public class SkillPackageValidator {
private static final String SKILL_MD_PATH = "SKILL.md";
private static final Set<String> DEFAULT_ALLOWED_EXTENSIONS = Set.of(
".md", ".txt", ".json", ".yaml", ".yml",
".js", ".ts", ".py", ".sh",
".png", ".jpg", ".svg"
);
private final SkillMetadataParser metadataParser; private final SkillMetadataParser metadataParser;
private final int maxFileCount; private final int maxFileCount;
private final long maxSingleFileSize; private final long maxSingleFileSize;
@ -26,7 +17,13 @@ public class SkillPackageValidator {
private final Set<String> allowedExtensions; private final Set<String> allowedExtensions;
public SkillPackageValidator(SkillMetadataParser metadataParser) { public SkillPackageValidator(SkillMetadataParser metadataParser) {
this(metadataParser, 100, 1024 * 1024, 10 * 1024 * 1024, DEFAULT_ALLOWED_EXTENSIONS); this(
metadataParser,
SkillPackagePolicy.MAX_FILE_COUNT,
SkillPackagePolicy.MAX_SINGLE_FILE_SIZE,
SkillPackagePolicy.MAX_TOTAL_PACKAGE_SIZE,
SkillPackagePolicy.ALLOWED_EXTENSIONS
);
} }
public SkillPackageValidator(SkillMetadataParser metadataParser, public SkillPackageValidator(SkillMetadataParser metadataParser,
@ -45,33 +42,38 @@ public class SkillPackageValidator {
public ValidationResult validate(List<PackageEntry> entries) { public ValidationResult validate(List<PackageEntry> entries) {
List<String> errors = new ArrayList<>(); List<String> errors = new ArrayList<>();
Set<String> seenPaths = new HashSet<>(); Set<String> normalizedPaths = new HashSet<>();
PackageEntry skillMd = null;
// 1. Check file count
if (entries.size() > maxFileCount) {
errors.add("Too many files: " + entries.size() + " (max: " + maxFileCount + ")");
}
// 2. Validate paths and duplicates
for (PackageEntry entry : entries) { for (PackageEntry entry : entries) {
String normalizedPath = validateAndNormalizePath(entry.path(), errors); String normalizedPath;
if (normalizedPath != null && !seenPaths.add(normalizedPath)) { try {
errors.add("Duplicate file path: " + normalizedPath); normalizedPath = SkillPackagePolicy.normalizeEntryPath(entry.path());
} catch (IllegalArgumentException e) {
errors.add(e.getMessage());
continue;
}
if (!normalizedPaths.add(normalizedPath)) {
errors.add("Duplicate package entry path: " + normalizedPath);
}
if (!hasAllowedExtension(normalizedPath)) {
errors.add("Disallowed file extension: " + normalizedPath);
}
if (SkillPackagePolicy.SKILL_MD_PATH.equals(normalizedPath) && skillMd == null) {
skillMd = entry;
} }
} }
// 3. Check SKILL.md exists at root // 1. Check SKILL.md exists at root
PackageEntry skillMd = entries.stream()
.filter(e -> e.path().equals(SKILL_MD_PATH))
.findFirst()
.orElse(null);
if (skillMd == null) { if (skillMd == null) {
errors.add("Missing required file: SKILL.md at root"); errors.add("Missing required file: SKILL.md at root");
return ValidationResult.fail(errors); return ValidationResult.fail(errors);
} }
// 4. Validate frontmatter // 2. Validate frontmatter
try { try {
String content = new String(skillMd.content()); String content = new String(skillMd.content());
metadataParser.parse(content); metadataParser.parse(content);
@ -82,23 +84,19 @@ public class SkillPackageValidator {
errors.add("Invalid SKILL.md frontmatter: " + detail); errors.add("Invalid SKILL.md frontmatter: " + detail);
} }
// 5. Check file extensions // 3. Check file count
for (PackageEntry entry : entries) { if (entries.size() > maxFileCount) {
String path = entry.path().toLowerCase(); errors.add("Too many files: " + entries.size() + " (max: " + maxFileCount + ")");
boolean hasAllowedExtension = allowedExtensions.stream().anyMatch(path::endsWith);
if (!hasAllowedExtension) {
errors.add("Disallowed file extension: " + path);
}
} }
// 6. Check single file size // 4. Check single file size
for (PackageEntry entry : entries) { for (PackageEntry entry : entries) {
if (entry.size() > maxSingleFileSize) { if (entry.size() > maxSingleFileSize) {
errors.add("File too large: " + entry.path() + " (" + entry.size() + " bytes, max: " + maxSingleFileSize + ")"); errors.add("File too large: " + entry.path() + " (" + entry.size() + " bytes, max: " + maxSingleFileSize + ")");
} }
} }
// 7. Check total package size // 5. Check total package size
long totalSize = entries.stream().mapToLong(PackageEntry::size).sum(); long totalSize = entries.stream().mapToLong(PackageEntry::size).sum();
if (totalSize > maxTotalPackageSize) { if (totalSize > maxTotalPackageSize) {
errors.add("Package too large: " + totalSize + " bytes (max: " + maxTotalPackageSize + ")"); errors.add("Package too large: " + totalSize + " bytes (max: " + maxTotalPackageSize + ")");
@ -107,35 +105,7 @@ public class SkillPackageValidator {
return errors.isEmpty() ? ValidationResult.pass() : ValidationResult.fail(errors); return errors.isEmpty() ? ValidationResult.pass() : ValidationResult.fail(errors);
} }
private String validateAndNormalizePath(String path, List<String> errors) { private boolean hasAllowedExtension(String normalizedPath) {
if (path == null || path.isBlank()) { return allowedExtensions.stream().anyMatch(normalizedPath::endsWith);
errors.add("Package entry path must not be blank");
return null;
}
if (path.contains("\\")) {
errors.add("Package entry must use '/' separators: " + path);
return null;
}
if (path.startsWith("/") || path.contains("//")) {
errors.add("Unsafe file path: " + path);
return null;
}
try {
Path normalized = Path.of(path).normalize();
String normalizedPath = normalized.toString().replace('\\', '/');
if (normalized.isAbsolute()
|| normalizedPath.isBlank()
|| normalizedPath.equals(".")
|| normalizedPath.equals("..")
|| normalizedPath.startsWith("../")) {
errors.add("Unsafe file path: " + path);
return null;
}
return normalizedPath;
} catch (InvalidPathException ex) {
errors.add("Invalid file path: " + path);
return null;
}
} }
} }

View file

@ -2,7 +2,6 @@ package com.iflytek.skillhub.domain.review;
import com.iflytek.skillhub.domain.event.SkillPublishedEvent; import com.iflytek.skillhub.domain.event.SkillPublishedEvent;
import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceType; import com.iflytek.skillhub.domain.namespace.NamespaceType;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
@ -17,10 +16,10 @@ import org.mockito.ArgumentCaptor;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.ApplicationEventPublisher;
import jakarta.persistence.EntityManager;
import java.util.*; import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*; import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
@ -35,7 +34,6 @@ class PromotionServiceTest {
@Mock private NamespaceRepository namespaceRepository; @Mock private NamespaceRepository namespaceRepository;
@Mock private ReviewPermissionChecker permissionChecker; @Mock private ReviewPermissionChecker permissionChecker;
@Mock private ApplicationEventPublisher eventPublisher; @Mock private ApplicationEventPublisher eventPublisher;
@Mock private EntityManager entityManager;
private PromotionService promotionService; private PromotionService promotionService;
@ -52,7 +50,7 @@ class PromotionServiceTest {
void setUp() { void setUp() {
promotionService = new PromotionService( promotionService = new PromotionService(
promotionRequestRepository, skillRepository, skillVersionRepository, promotionRequestRepository, skillRepository, skillVersionRepository,
skillFileRepository, namespaceRepository, permissionChecker, eventPublisher, entityManager); skillFileRepository, namespaceRepository, permissionChecker, eventPublisher);
} }
private static void setField(Object target, String fieldName, Object value) { private static void setField(Object target, String fieldName, Object value) {
@ -123,7 +121,7 @@ class PromotionServiceTest {
when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(sourceSkill)); when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(sourceSkill));
when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(sourceVersion)); when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(sourceVersion));
when(permissionChecker.canSubmitPromotion(eq(sourceSkill), eq(USER_ID), anyMap(), anySet())).thenReturn(true); when(permissionChecker.canSubmitPromotion(sourceSkill, USER_ID, Map.of())).thenReturn(true);
when(namespaceRepository.findById(TARGET_NAMESPACE_ID)).thenReturn(Optional.of(globalNs)); when(namespaceRepository.findById(TARGET_NAMESPACE_ID)).thenReturn(Optional.of(globalNs));
when(promotionRequestRepository.findBySourceVersionIdAndStatus(SOURCE_VERSION_ID, ReviewTaskStatus.PENDING)) when(promotionRequestRepository.findBySourceVersionIdAndStatus(SOURCE_VERSION_ID, ReviewTaskStatus.PENDING))
.thenReturn(Optional.empty()); .thenReturn(Optional.empty());
@ -135,8 +133,7 @@ class PromotionServiceTest {
}); });
PromotionRequest result = promotionService.submitPromotion( PromotionRequest result = promotionService.submitPromotion(
SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID, SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID, Map.of());
Map.of(5L, NamespaceRole.OWNER), Set.of());
assertNotNull(result); assertNotNull(result);
assertEquals(SOURCE_SKILL_ID, result.getSourceSkillId()); assertEquals(SOURCE_SKILL_ID, result.getSourceSkillId());
@ -151,7 +148,7 @@ class PromotionServiceTest {
when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.empty()); when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.empty());
assertThrows(DomainNotFoundException.class, assertThrows(DomainNotFoundException.class,
() -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID, Map.of(), Set.of())); () -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID, Map.of()));
} }
@Test @Test
@ -160,7 +157,7 @@ class PromotionServiceTest {
when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.empty()); when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.empty());
assertThrows(DomainNotFoundException.class, assertThrows(DomainNotFoundException.class,
() -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID, Map.of(), Set.of())); () -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID, Map.of()));
} }
@Test @Test
@ -173,7 +170,7 @@ class PromotionServiceTest {
when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(sv)); when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(sv));
assertThrows(DomainBadRequestException.class, assertThrows(DomainBadRequestException.class,
() -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID, Map.of(), Set.of())); () -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID, Map.of()));
} }
@Test @Test
@ -186,42 +183,99 @@ class PromotionServiceTest {
when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(sv)); when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(sv));
assertThrows(DomainBadRequestException.class, assertThrows(DomainBadRequestException.class,
() -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID, Map.of(), Set.of())); () -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID, Map.of()));
} }
@Test @Test
void shouldThrowWhenTargetNamespaceNotFound() { void shouldThrowWhenTargetNamespaceNotFound() {
when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(createSourceSkill())); Skill sourceSkill = createSourceSkill();
when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(sourceSkill));
when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(createPublishedVersion())); when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(createPublishedVersion()));
when(permissionChecker.canSubmitPromotion(any(), eq(USER_ID), anyMap(), anySet())).thenReturn(true); when(permissionChecker.canSubmitPromotion(sourceSkill, USER_ID, Map.of())).thenReturn(true);
when(namespaceRepository.findById(TARGET_NAMESPACE_ID)).thenReturn(Optional.empty()); when(namespaceRepository.findById(TARGET_NAMESPACE_ID)).thenReturn(Optional.empty());
assertThrows(DomainNotFoundException.class, assertThrows(DomainNotFoundException.class,
() -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID, Map.of(), Set.of())); () -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID, Map.of()));
} }
@Test @Test
void shouldThrowWhenTargetNamespaceNotGlobal() { void shouldThrowWhenTargetNamespaceNotGlobal() {
when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(createSourceSkill())); Skill sourceSkill = createSourceSkill();
when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(sourceSkill));
when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(createPublishedVersion())); when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(createPublishedVersion()));
when(permissionChecker.canSubmitPromotion(any(), eq(USER_ID), anyMap(), anySet())).thenReturn(true); when(permissionChecker.canSubmitPromotion(sourceSkill, USER_ID, Map.of())).thenReturn(true);
when(namespaceRepository.findById(TARGET_NAMESPACE_ID)).thenReturn(Optional.of(createTeamNamespace())); when(namespaceRepository.findById(TARGET_NAMESPACE_ID)).thenReturn(Optional.of(createTeamNamespace()));
assertThrows(DomainBadRequestException.class, assertThrows(DomainBadRequestException.class,
() -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID, Map.of(), Set.of())); () -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID, Map.of()));
} }
@Test @Test
void shouldThrowWhenDuplicatePendingExists() { void shouldThrowWhenDuplicatePendingExists() {
when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(createSourceSkill())); Skill sourceSkill = createSourceSkill();
when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(sourceSkill));
when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(createPublishedVersion())); when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(createPublishedVersion()));
when(permissionChecker.canSubmitPromotion(any(), eq(USER_ID), anyMap(), anySet())).thenReturn(true); when(permissionChecker.canSubmitPromotion(sourceSkill, USER_ID, Map.of())).thenReturn(true);
when(namespaceRepository.findById(TARGET_NAMESPACE_ID)).thenReturn(Optional.of(createGlobalNamespace())); when(namespaceRepository.findById(TARGET_NAMESPACE_ID)).thenReturn(Optional.of(createGlobalNamespace()));
when(promotionRequestRepository.findBySourceVersionIdAndStatus(SOURCE_VERSION_ID, ReviewTaskStatus.PENDING)) when(promotionRequestRepository.findBySourceVersionIdAndStatus(SOURCE_VERSION_ID, ReviewTaskStatus.PENDING))
.thenReturn(Optional.of(createPendingPromotion())); .thenReturn(Optional.of(createPendingPromotion()));
assertThrows(DomainBadRequestException.class, assertThrows(DomainBadRequestException.class,
() -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID, Map.of(), Set.of())); () -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID, Map.of()));
}
@Test
void shouldThrowWhenSubmitterIsNotOwnerOrNamespaceAdmin() {
Skill sourceSkill = createSourceSkill();
SkillVersion sourceVersion = createPublishedVersion();
when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(sourceSkill));
when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(sourceVersion));
when(permissionChecker.canSubmitPromotion(
sourceSkill,
"user-999",
Map.of(sourceSkill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.MEMBER)))
.thenReturn(false);
assertThrows(DomainForbiddenException.class,
() -> promotionService.submitPromotion(
SOURCE_SKILL_ID,
SOURCE_VERSION_ID,
TARGET_NAMESPACE_ID,
"user-999",
Map.of(sourceSkill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.MEMBER)
));
verify(promotionRequestRepository, never()).save(any(PromotionRequest.class));
}
@Test
void shouldAllowNamespaceAdminToSubmitPromotionForForeignSkill() {
Skill sourceSkill = createSourceSkill();
SkillVersion sourceVersion = createPublishedVersion();
Namespace globalNs = createGlobalNamespace();
when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(sourceSkill));
when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(sourceVersion));
when(permissionChecker.canSubmitPromotion(
sourceSkill,
"user-999",
Map.of(sourceSkill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.ADMIN)))
.thenReturn(true);
when(namespaceRepository.findById(TARGET_NAMESPACE_ID)).thenReturn(Optional.of(globalNs));
when(promotionRequestRepository.findBySourceVersionIdAndStatus(SOURCE_VERSION_ID, ReviewTaskStatus.PENDING))
.thenReturn(Optional.empty());
when(promotionRequestRepository.save(any(PromotionRequest.class)))
.thenAnswer(inv -> inv.getArgument(0));
PromotionRequest result = promotionService.submitPromotion(
SOURCE_SKILL_ID,
SOURCE_VERSION_ID,
TARGET_NAMESPACE_ID,
"user-999",
Map.of(sourceSkill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.ADMIN)
);
assertNotNull(result);
} }
} }
@ -240,9 +294,6 @@ class PromotionServiceTest {
when(promotionRequestRepository.updateStatusWithVersion( when(promotionRequestRepository.updateStatusWithVersion(
PROMOTION_ID, ReviewTaskStatus.APPROVED, REVIEWER_ID, "LGTM", null, pr.getVersion())) PROMOTION_ID, ReviewTaskStatus.APPROVED, REVIEWER_ID, "LGTM", null, pr.getVersion()))
.thenReturn(1); .thenReturn(1);
when(promotionRequestRepository.updateStatusWithVersion(
PROMOTION_ID, ReviewTaskStatus.APPROVED, REVIEWER_ID, "LGTM", NEW_SKILL_ID, pr.getVersion() + 1))
.thenReturn(1);
when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(sourceSkill)); when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(sourceSkill));
when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(sourceVersion)); when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(sourceVersion));
when(skillRepository.save(any(Skill.class))).thenAnswer(inv -> { when(skillRepository.save(any(Skill.class))).thenAnswer(inv -> {
@ -302,9 +353,8 @@ class PromotionServiceTest {
assertEquals(NEW_VERSION_ID, event.versionId()); assertEquals(NEW_VERSION_ID, event.versionId());
assertEquals(REVIEWER_ID, event.publisherId()); assertEquals(REVIEWER_ID, event.publisherId());
verify(entityManager).detach(pr); // Verify targetSkillId updated on promotion request
verify(promotionRequestRepository).updateStatusWithVersion( verify(promotionRequestRepository).save(pr);
PROMOTION_ID, ReviewTaskStatus.APPROVED, REVIEWER_ID, "LGTM", NEW_SKILL_ID, pr.getVersion() + 1);
assertEquals(NEW_SKILL_ID, pr.getTargetSkillId()); assertEquals(NEW_SKILL_ID, pr.getTargetSkillId());
} }
@ -357,9 +407,6 @@ class PromotionServiceTest {
when(promotionRequestRepository.findById(PROMOTION_ID)).thenReturn(Optional.of(pr)); when(promotionRequestRepository.findById(PROMOTION_ID)).thenReturn(Optional.of(pr));
when(permissionChecker.canReviewPromotion(pr, REVIEWER_ID, Set.of("SKILL_ADMIN"))).thenReturn(true); when(permissionChecker.canReviewPromotion(pr, REVIEWER_ID, Set.of("SKILL_ADMIN"))).thenReturn(true);
when(promotionRequestRepository.updateStatusWithVersion(any(), any(), any(), any(), any(), any())).thenReturn(1); when(promotionRequestRepository.updateStatusWithVersion(any(), any(), any(), any(), any(), any())).thenReturn(1);
when(promotionRequestRepository.updateStatusWithVersion(
PROMOTION_ID, ReviewTaskStatus.APPROVED, REVIEWER_ID, "ok", NEW_SKILL_ID, pr.getVersion() + 1))
.thenReturn(1);
when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(sourceSkill)); when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(sourceSkill));
when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(sourceVersion)); when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(sourceVersion));
when(skillRepository.save(any(Skill.class))).thenAnswer(inv -> { when(skillRepository.save(any(Skill.class))).thenAnswer(inv -> {
@ -396,15 +443,14 @@ class PromotionServiceTest {
when(promotionRequestRepository.updateStatusWithVersion( when(promotionRequestRepository.updateStatusWithVersion(
PROMOTION_ID, ReviewTaskStatus.REJECTED, REVIEWER_ID, "Not ready", null, pr.getVersion())) PROMOTION_ID, ReviewTaskStatus.REJECTED, REVIEWER_ID, "Not ready", null, pr.getVersion()))
.thenReturn(1); .thenReturn(1);
when(promotionRequestRepository.findById(PROMOTION_ID)).thenReturn(Optional.of(pr));
PromotionRequest result = promotionService.rejectPromotion( PromotionRequest result = promotionService.rejectPromotion(
PROMOTION_ID, REVIEWER_ID, "Not ready", Set.of("SKILL_ADMIN")); PROMOTION_ID, REVIEWER_ID, "Not ready", Set.of("SKILL_ADMIN"));
assertNotNull(result); assertNotNull(result);
assertEquals(ReviewTaskStatus.REJECTED, result.getStatus());
assertEquals(REVIEWER_ID, result.getReviewedBy());
verify(promotionRequestRepository).updateStatusWithVersion( verify(promotionRequestRepository).updateStatusWithVersion(
PROMOTION_ID, ReviewTaskStatus.REJECTED, REVIEWER_ID, "Not ready", null, pr.getVersion()); PROMOTION_ID, ReviewTaskStatus.REJECTED, REVIEWER_ID, "Not ready", null, pr.getVersion());
verify(entityManager).detach(pr);
verify(eventPublisher, never()).publishEvent(any()); verify(eventPublisher, never()).publishEvent(any());
} }

View file

@ -2,6 +2,8 @@ package com.iflytek.skillhub.domain.review;
import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.namespace.NamespaceType; import com.iflytek.skillhub.domain.namespace.NamespaceType;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.util.Map; import java.util.Map;
@ -24,18 +26,18 @@ class ReviewPermissionCheckerTest {
} }
@Test @Test
void skillAdminCanReviewOwnSubmission() { void skillAdminCannotReviewOwnSubmission() {
String userId = "user-1"; String userId = "user-1";
ReviewTask task = new ReviewTask(1L, 10L, userId); ReviewTask task = new ReviewTask(1L, 10L, userId);
assertTrue(checker.canReview(task, userId, assertFalse(checker.canReview(task, userId,
NamespaceType.TEAM, Map.of(), Set.of("SKILL_ADMIN"))); NamespaceType.TEAM, Map.of(), Set.of("SKILL_ADMIN")));
} }
@Test @Test
void superAdminCanReviewOwnSubmission() { void superAdminCannotReviewOwnSubmission() {
String userId = "user-1"; String userId = "user-1";
ReviewTask task = new ReviewTask(1L, 10L, userId); ReviewTask task = new ReviewTask(1L, 10L, userId);
assertTrue(checker.canReview(task, userId, assertFalse(checker.canReview(task, userId,
NamespaceType.TEAM, Map.of(), Set.of("SUPER_ADMIN"))); NamespaceType.TEAM, Map.of(), Set.of("SUPER_ADMIN")));
} }
@ -105,6 +107,53 @@ class ReviewPermissionCheckerTest {
// --- canReviewPromotion tests --- // --- canReviewPromotion tests ---
@Test
void memberCanSubmitReview() {
assertTrue(checker.canSubmitReview(10L, Map.of(10L, NamespaceRole.MEMBER)));
}
@Test
void outsiderCannotSubmitReview() {
assertFalse(checker.canSubmitReview(10L, Map.of()));
}
@Test
void teamAdminCanManagePendingReviewList() {
assertTrue(checker.canManageNamespaceReviews(
10L, NamespaceType.TEAM, Map.of(10L, NamespaceRole.ADMIN), Set.of()));
}
@Test
void submitterCanReadOwnReview() {
ReviewTask task = new ReviewTask(1L, 10L, "user-1");
assertTrue(checker.canReadReview(task, "user-1",
NamespaceType.TEAM, Map.of(), Set.of()));
}
@Test
void ownerCanSubmitPromotion() {
Skill sourceSkill = new Skill(10L, "skill-a", "user-1", SkillVisibility.PUBLIC);
assertTrue(checker.canSubmitPromotion(sourceSkill, "user-1", Map.of()));
}
@Test
void teamAdminCanSubmitPromotionForForeignSkill() {
Skill sourceSkill = new Skill(10L, "skill-a", "user-2", SkillVisibility.PUBLIC);
assertTrue(checker.canSubmitPromotion(sourceSkill, "user-1",
Map.of(10L, NamespaceRole.ADMIN)));
}
@Test
void submitterCanReadOwnPromotion() {
PromotionRequest req = new PromotionRequest(1L, 1L, 1L, "user-1");
assertTrue(checker.canReadPromotion(req, "user-1", Set.of()));
}
@Test
void skillAdminCanListPendingPromotions() {
assertTrue(checker.canListPendingPromotions(Set.of("SKILL_ADMIN")));
}
@Test @Test
void skillAdminCanReviewPromotion() { void skillAdminCanReviewPromotion() {
PromotionRequest req = new PromotionRequest(1L, 1L, 1L, "user-2"); PromotionRequest req = new PromotionRequest(1L, 1L, 1L, "user-2");

View file

@ -1,5 +1,6 @@
package com.iflytek.skillhub.domain.review; package com.iflytek.skillhub.domain.review;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.domain.event.SkillPublishedEvent; import com.iflytek.skillhub.domain.event.SkillPublishedEvent;
import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
@ -13,7 +14,7 @@ import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.skill.SkillVersionStatus; import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.domain.skill.SkillVisibility;
import jakarta.persistence.EntityManager; import com.iflytek.skillhub.domain.skill.metadata.SkillMetadata;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@ -42,7 +43,6 @@ class ReviewServiceTest {
@Mock private NamespaceRepository namespaceRepository; @Mock private NamespaceRepository namespaceRepository;
@Mock private ReviewPermissionChecker permissionChecker; @Mock private ReviewPermissionChecker permissionChecker;
@Mock private ApplicationEventPublisher eventPublisher; @Mock private ApplicationEventPublisher eventPublisher;
@Mock private EntityManager entityManager;
private ReviewService reviewService; private ReviewService reviewService;
@ -52,12 +52,14 @@ class ReviewServiceTest {
private static final String REVIEWER_ID = "user-200"; private static final String REVIEWER_ID = "user-200";
private static final Long REVIEW_TASK_ID = 1L; private static final Long REVIEW_TASK_ID = 1L;
private static final Long SKILL_ID = 30L; private static final Long SKILL_ID = 30L;
private ObjectMapper objectMapper;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
objectMapper = new ObjectMapper();
reviewService = new ReviewService( reviewService = new ReviewService(
reviewTaskRepository, skillVersionRepository, skillRepository, reviewTaskRepository, skillVersionRepository, skillRepository,
namespaceRepository, permissionChecker, eventPublisher, entityManager); namespaceRepository, permissionChecker, eventPublisher, objectMapper);
} }
private SkillVersion createDraftSkillVersion() { private SkillVersion createDraftSkillVersion() {
@ -109,11 +111,17 @@ class ReviewServiceTest {
Skill skill = createSkill(); Skill skill = createSkill();
when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv));
when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill)); when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill));
when(permissionChecker.canSubmitForReview(eq(skill), eq(USER_ID), anyMap(), anySet())).thenReturn(true); when(permissionChecker.canSubmitReview(
NAMESPACE_ID,
Map.of(NAMESPACE_ID, NamespaceRole.MEMBER))).thenReturn(true);
ReviewTask savedTask = createPendingReviewTask(); ReviewTask savedTask = createPendingReviewTask();
when(reviewTaskRepository.save(any(ReviewTask.class))).thenReturn(savedTask); when(reviewTaskRepository.save(any(ReviewTask.class))).thenReturn(savedTask);
ReviewTask result = reviewService.submitReview(SKILL_VERSION_ID, USER_ID, Map.of(), Set.of()); ReviewTask result = reviewService.submitReview(
SKILL_VERSION_ID,
USER_ID,
Map.of(NAMESPACE_ID, NamespaceRole.MEMBER)
);
assertNotNull(result); assertNotNull(result);
assertEquals(SkillVersionStatus.PENDING_REVIEW, sv.getStatus()); assertEquals(SkillVersionStatus.PENDING_REVIEW, sv.getStatus());
@ -126,19 +134,17 @@ class ReviewServiceTest {
when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.empty()); when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.empty());
assertThrows(DomainNotFoundException.class, assertThrows(DomainNotFoundException.class,
() -> reviewService.submitReview(SKILL_VERSION_ID, USER_ID, Map.of(), Set.of())); () -> reviewService.submitReview(SKILL_VERSION_ID, USER_ID, Map.of()));
} }
@Test @Test
void shouldThrowWhenStatusNotDraft() { void shouldThrowWhenStatusNotDraft() {
SkillVersion sv = createPendingReviewSkillVersion(); SkillVersion sv = createPendingReviewSkillVersion();
Skill skill = createSkill();
when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv));
when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill)); when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(createSkill()));
when(permissionChecker.canSubmitForReview(eq(skill), eq(USER_ID), anyMap(), anySet())).thenReturn(true);
assertThrows(DomainBadRequestException.class, assertThrows(DomainBadRequestException.class,
() -> reviewService.submitReview(SKILL_VERSION_ID, USER_ID, Map.of(), Set.of())); () -> reviewService.submitReview(SKILL_VERSION_ID, USER_ID, Map.of(NAMESPACE_ID, NamespaceRole.MEMBER)));
} }
@Test @Test
@ -147,12 +153,31 @@ class ReviewServiceTest {
Skill skill = createSkill(); Skill skill = createSkill();
when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv));
when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill)); when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill));
when(permissionChecker.canSubmitForReview(eq(skill), eq(USER_ID), anyMap(), anySet())).thenReturn(true); when(permissionChecker.canSubmitReview(
NAMESPACE_ID,
Map.of(NAMESPACE_ID, NamespaceRole.MEMBER))).thenReturn(true);
when(reviewTaskRepository.save(any(ReviewTask.class))) when(reviewTaskRepository.save(any(ReviewTask.class)))
.thenThrow(new DataIntegrityViolationException("duplicate")); .thenThrow(new DataIntegrityViolationException("duplicate"));
assertThrows(DomainBadRequestException.class, assertThrows(DomainBadRequestException.class,
() -> reviewService.submitReview(SKILL_VERSION_ID, USER_ID, Map.of(), Set.of())); () -> reviewService.submitReview(
SKILL_VERSION_ID,
USER_ID,
Map.of(NAMESPACE_ID, NamespaceRole.MEMBER)
));
}
@Test
void shouldThrowWhenSubmitterLacksNamespaceMembership() {
SkillVersion sv = createDraftSkillVersion();
Skill skill = createSkill();
when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv));
when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill));
when(permissionChecker.canSubmitReview(NAMESPACE_ID, Map.of())).thenReturn(false);
assertThrows(DomainForbiddenException.class,
() -> reviewService.submitReview(SKILL_VERSION_ID, USER_ID, Map.of()));
verify(reviewTaskRepository, never()).save(any(ReviewTask.class));
} }
} }
@ -165,6 +190,12 @@ class ReviewServiceTest {
Namespace ns = createTeamNamespace(); Namespace ns = createTeamNamespace();
SkillVersion sv = createPendingReviewSkillVersion(); SkillVersion sv = createPendingReviewSkillVersion();
Skill skill = createSkill(); Skill skill = createSkill();
skill.setDisplayName("Published Name");
skill.setSummary("Published Summary");
skill.setUpdatedBy("previous-reviewer");
assertDoesNotThrow(() -> sv.setParsedMetadataJson(objectMapper.writeValueAsString(
new SkillMetadata("Approved Name", "Approved Summary", "1.0.0", "Body", Map.of())
)));
when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task)); when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task));
when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(ns)); when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(ns));
@ -175,19 +206,19 @@ class ReviewServiceTest {
.thenReturn(1); .thenReturn(1);
when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv));
when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill)); when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill));
when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task));
ReviewTask result = reviewService.approveReview( ReviewTask result = reviewService.approveReview(
REVIEW_TASK_ID, REVIEWER_ID, "LGTM", REVIEW_TASK_ID, REVIEWER_ID, "LGTM",
Map.of(NAMESPACE_ID, NamespaceRole.ADMIN), Set.of()); Map.of(NAMESPACE_ID, NamespaceRole.ADMIN), Set.of());
assertNotNull(result); assertNotNull(result);
assertEquals(ReviewTaskStatus.APPROVED, result.getStatus());
assertEquals(REVIEWER_ID, result.getReviewedBy());
assertEquals("LGTM", result.getReviewComment());
assertNotNull(result.getReviewedAt());
assertEquals(SkillVersionStatus.PUBLISHED, sv.getStatus()); assertEquals(SkillVersionStatus.PUBLISHED, sv.getStatus());
assertNotNull(sv.getPublishedAt()); assertNotNull(sv.getPublishedAt());
assertEquals(SKILL_VERSION_ID, skill.getLatestVersionId()); assertEquals(SKILL_VERSION_ID, skill.getLatestVersionId());
assertEquals("Approved Name", skill.getDisplayName());
assertEquals("Approved Summary", skill.getSummary());
assertEquals(REVIEWER_ID, skill.getUpdatedBy());
verify(eventPublisher).publishEvent(any(SkillPublishedEvent.class)); verify(eventPublisher).publishEvent(any(SkillPublishedEvent.class));
} }
@ -204,6 +235,7 @@ class ReviewServiceTest {
when(reviewTaskRepository.updateStatusWithVersion(any(), any(), any(), any(), any())).thenReturn(1); when(reviewTaskRepository.updateStatusWithVersion(any(), any(), any(), any(), any())).thenReturn(1);
when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv));
when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill)); when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill));
when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task));
reviewService.approveReview(REVIEW_TASK_ID, REVIEWER_ID, "ok", reviewService.approveReview(REVIEW_TASK_ID, REVIEWER_ID, "ok",
Map.of(NAMESPACE_ID, NamespaceRole.ADMIN), Set.of()); Map.of(NAMESPACE_ID, NamespaceRole.ADMIN), Set.of());
@ -275,16 +307,13 @@ class ReviewServiceTest {
when(permissionChecker.canReview(any(), any(), any(), anyMap(), anySet())).thenReturn(true); when(permissionChecker.canReview(any(), any(), any(), anyMap(), anySet())).thenReturn(true);
when(reviewTaskRepository.updateStatusWithVersion(any(), any(), any(), any(), any())).thenReturn(1); when(reviewTaskRepository.updateStatusWithVersion(any(), any(), any(), any(), any())).thenReturn(1);
when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv));
when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task));
ReviewTask result = reviewService.rejectReview( ReviewTask result = reviewService.rejectReview(
REVIEW_TASK_ID, REVIEWER_ID, "needs work", REVIEW_TASK_ID, REVIEWER_ID, "needs work",
Map.of(NAMESPACE_ID, NamespaceRole.ADMIN), Set.of()); Map.of(NAMESPACE_ID, NamespaceRole.ADMIN), Set.of());
assertNotNull(result); assertNotNull(result);
assertEquals(ReviewTaskStatus.REJECTED, result.getStatus());
assertEquals(REVIEWER_ID, result.getReviewedBy());
assertEquals("needs work", result.getReviewComment());
assertNotNull(result.getReviewedAt());
assertEquals(SkillVersionStatus.REJECTED, sv.getStatus()); assertEquals(SkillVersionStatus.REJECTED, sv.getStatus());
verify(skillVersionRepository).save(sv); verify(skillVersionRepository).save(sv);
verify(eventPublisher, never()).publishEvent(any(SkillPublishedEvent.class)); verify(eventPublisher, never()).publishEvent(any(SkillPublishedEvent.class));

View file

@ -176,4 +176,49 @@ class SkillPackageValidatorTest {
assertFalse(result.passed()); assertFalse(result.passed());
assertTrue(result.errors().stream().anyMatch(e -> e.contains("Package too large"))); assertTrue(result.errors().stream().anyMatch(e -> e.contains("Package too large")));
} }
@Test
void testPathTraversalEntryRejected() {
String skillMdContent = """
---
name: test-skill
description: A test skill
version: 1.0.0
---
Body
""";
List<PackageEntry> entries = List.of(
new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown"),
new PackageEntry("../secrets.txt", "hidden".getBytes(), 6, "text/plain")
);
ValidationResult result = validator.validate(entries);
assertFalse(result.passed());
assertTrue(result.errors().stream().anyMatch(e -> e.contains("escapes package root")));
}
@Test
void testDuplicateNormalizedPathRejected() {
String skillMdContent = """
---
name: test-skill
description: A test skill
version: 1.0.0
---
Body
""";
List<PackageEntry> entries = List.of(
new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown"),
new PackageEntry("docs\\guide.md", "first".getBytes(), 5, "text/markdown"),
new PackageEntry("docs/guide.md", "second".getBytes(), 6, "text/markdown")
);
ValidationResult result = validator.validate(entries);
assertFalse(result.passed());
assertTrue(result.errors().stream().anyMatch(e -> e.contains("Duplicate package entry path: docs/guide.md")));
}
} }

View file

@ -6,13 +6,14 @@ import com.iflytek.skillhub.domain.user.UserStatus;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param; import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
@Repository @Repository
public interface UserAccountJpaRepository public interface UserAccountJpaRepository
extends JpaRepository<UserAccount, String>, UserAccountRepository { extends JpaRepository<UserAccount, String>, JpaSpecificationExecutor<UserAccount>, UserAccountRepository {
@Override @Override
@Query(""" @Query("""

View file

@ -66,7 +66,7 @@ public class LocalFileStorageService implements ObjectStorageService {
private Path resolve(String key) { private Path resolve(String key) {
Path resolved = basePath.resolve(key).normalize(); Path resolved = basePath.resolve(key).normalize();
if (!resolved.startsWith(basePath)) { if (!resolved.startsWith(basePath)) {
throw new IllegalArgumentException("Resolved path escapes storage base path: " + key); throw new IllegalArgumentException("Invalid storage key: " + key);
} }
return resolved; return resolved;
} }

View file

@ -1,24 +1,110 @@
package com.iflytek.skillhub.storage; package com.iflytek.skillhub.storage;
import static org.assertj.core.api.Assertions.assertThat; import org.junit.jupiter.api.BeforeEach;
import java.nio.file.Files;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.api.io.TempDir;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class LocalFileStorageServiceTest { class LocalFileStorageServiceTest {
@TempDir @TempDir
java.nio.file.Path tempDir; Path tempDir;
private LocalFileStorageService storageService;
@BeforeEach
void setUp() {
StorageProperties props = new StorageProperties();
props.getLocal().setBasePath(tempDir.toString());
storageService = new LocalFileStorageService(props);
}
@Test @Test
void generatePresignedUrl_returnsNullForLocalStorage() throws Exception { void shouldPutAndGetObject() throws Exception {
String key = "skills/1/1/SKILL.md";
byte[] content = "# Hello".getBytes(StandardCharsets.UTF_8);
storageService.putObject(key, new ByteArrayInputStream(content), content.length, "text/markdown");
try (InputStream result = storageService.getObject(key)) {
assertArrayEquals(content, result.readAllBytes());
}
}
@Test
void shouldCheckExistence() {
assertFalse(storageService.exists("test/exists.txt"));
byte[] content = "data".getBytes(StandardCharsets.UTF_8);
storageService.putObject("test/exists.txt", new ByteArrayInputStream(content), content.length, "text/plain");
assertTrue(storageService.exists("test/exists.txt"));
}
@Test
void shouldDeleteObject() {
byte[] content = "data".getBytes(StandardCharsets.UTF_8);
storageService.putObject("test/delete.txt", new ByteArrayInputStream(content), content.length, "text/plain");
assertTrue(storageService.exists("test/delete.txt"));
storageService.deleteObject("test/delete.txt");
assertFalse(storageService.exists("test/delete.txt"));
}
@Test
void shouldDeleteMultipleObjects() {
byte[] content = "data".getBytes(StandardCharsets.UTF_8);
storageService.putObject("a/1.txt", new ByteArrayInputStream(content), content.length, "text/plain");
storageService.putObject("a/2.txt", new ByteArrayInputStream(content), content.length, "text/plain");
storageService.deleteObjects(List.of("a/1.txt", "a/2.txt"));
assertFalse(storageService.exists("a/1.txt"));
assertFalse(storageService.exists("a/2.txt"));
}
@Test
void shouldGetMetadata() {
byte[] content = "hello world".getBytes(StandardCharsets.UTF_8);
storageService.putObject("test/meta.txt", new ByteArrayInputStream(content), content.length, "text/plain");
ObjectMetadata metadata = storageService.getMetadata("test/meta.txt");
assertEquals(content.length, metadata.size());
assertNotNull(metadata.lastModified());
}
@Test
void shouldRejectPathTraversalKeys() {
byte[] content = "data".getBytes(StandardCharsets.UTF_8);
IllegalArgumentException putError = assertThrows(
IllegalArgumentException.class,
() -> storageService.putObject("../escape.txt", new ByteArrayInputStream(content), content.length, "text/plain")
);
assertEquals("Invalid storage key: ../escape.txt", putError.getMessage());
IllegalArgumentException getError = assertThrows(
IllegalArgumentException.class,
() -> storageService.getObject("..\\escape.txt")
);
assertEquals("Invalid storage key: ..\\escape.txt", getError.getMessage());
}
@Test
void generatePresignedUrlReturnsNullForLocalStorage() throws Exception {
StorageProperties properties = new StorageProperties(); StorageProperties properties = new StorageProperties();
properties.getLocal().setBasePath(tempDir.toString()); properties.getLocal().setBasePath(tempDir.toString());
Files.createDirectories(tempDir); Files.createDirectories(tempDir);
LocalFileStorageService service = new LocalFileStorageService(properties); LocalFileStorageService service = new LocalFileStorageService(properties);
assertThat(service.generatePresignedUrl("packages/demo.zip", java.time.Duration.ofMinutes(10))).isNull(); assertThat(service.generatePresignedUrl("packages/demo.zip", Duration.ofMinutes(10))).isNull();
} }
} }

View file

@ -1,44 +1,77 @@
import { lazy, type ComponentType } from 'react' import { lazy, Suspense, type ComponentType } from 'react'
import { createRouter, createRoute, createRootRoute, redirect } from '@tanstack/react-router' import { createRouter, createRoute, createRootRoute, redirect } from '@tanstack/react-router'
import { Layout } from './layout' import { Layout } from './layout'
import { getCurrentUser } from '@/api/client' import { getCurrentUser } from '@/api/client'
function lazyRouteComponent<TModule extends Record<string, unknown>>( function createLazyRouteComponent<TModule extends Record<string, unknown>>(
importer: () => Promise<TModule>, importer: () => Promise<TModule>,
exportName: keyof TModule, exportName: keyof TModule,
) { ) {
const LazyComponent = lazy(async () => { const LazyComponent = lazy(async () => {
const module = await importer() const module = await importer()
return { default: module[exportName] as ComponentType } return { default: module[exportName] as ComponentType<any> }
}) })
return LazyComponent return function LazyRouteComponent(props: Record<string, unknown>) {
return (
<Suspense
fallback={
<div className="flex min-h-[40vh] items-center justify-center text-sm text-muted-foreground">
Loading...
</div>
}
>
<LazyComponent {...props} />
</Suspense>
)
}
} }
const HomePage = lazyRouteComponent(() => import('@/pages/home'), 'HomePage') const HomePage = createLazyRouteComponent(() => import('@/pages/home'), 'HomePage')
const LoginPage = lazyRouteComponent(() => import('@/pages/login'), 'LoginPage') const LoginPage = createLazyRouteComponent(() => import('@/pages/login'), 'LoginPage')
const RegisterPage = lazyRouteComponent(() => import('@/pages/register'), 'RegisterPage') const RegisterPage = createLazyRouteComponent(() => import('@/pages/register'), 'RegisterPage')
const PrivacyPolicyPage = lazyRouteComponent(() => import('@/pages/privacy'), 'PrivacyPolicyPage') const PrivacyPolicyPage = createLazyRouteComponent(() => import('@/pages/privacy'), 'PrivacyPolicyPage')
const SearchPage = lazyRouteComponent(() => import('@/pages/search'), 'SearchPage') const SearchPage = createLazyRouteComponent(() => import('@/pages/search'), 'SearchPage')
const TermsOfServicePage = lazyRouteComponent(() => import('@/pages/terms'), 'TermsOfServicePage') const TermsOfServicePage = createLazyRouteComponent(() => import('@/pages/terms'), 'TermsOfServicePage')
const NamespacePage = lazyRouteComponent(() => import('@/pages/namespace'), 'NamespacePage') const NamespacePage = createLazyRouteComponent(() => import('@/pages/namespace'), 'NamespacePage')
const SkillDetailPage = lazyRouteComponent(() => import('@/pages/skill-detail'), 'SkillDetailPage') const SkillDetailPage = createLazyRouteComponent(() => import('@/pages/skill-detail'), 'SkillDetailPage')
const DashboardPage = lazyRouteComponent(() => import('@/pages/dashboard'), 'DashboardPage') const DashboardPage = createLazyRouteComponent(() => import('@/pages/dashboard'), 'DashboardPage')
const MySkillsPage = lazyRouteComponent(() => import('@/pages/dashboard/my-skills'), 'MySkillsPage') const MySkillsPage = createLazyRouteComponent(() => import('@/pages/dashboard/my-skills'), 'MySkillsPage')
const PublishPage = lazyRouteComponent(() => import('@/pages/dashboard/publish'), 'PublishPage') const PublishPage = createLazyRouteComponent(() => import('@/pages/dashboard/publish'), 'PublishPage')
const MyNamespacesPage = lazyRouteComponent(() => import('@/pages/dashboard/my-namespaces'), 'MyNamespacesPage') const MyNamespacesPage = createLazyRouteComponent(
const NamespaceMembersPage = lazyRouteComponent(() => import('@/pages/dashboard/namespace-members'), 'NamespaceMembersPage') () => import('@/pages/dashboard/my-namespaces'),
const NamespaceReviewsPage = lazyRouteComponent(() => import('@/pages/dashboard/namespace-reviews'), 'NamespaceReviewsPage') 'MyNamespacesPage',
const ReviewsPage = lazyRouteComponent(() => import('@/pages/dashboard/reviews'), 'ReviewsPage') )
const ReviewDetailPage = lazyRouteComponent(() => import('@/pages/dashboard/review-detail'), 'ReviewDetailPage') const NamespaceMembersPage = createLazyRouteComponent(
const PromotionsPage = lazyRouteComponent(() => import('@/pages/dashboard/promotions'), 'PromotionsPage') () => import('@/pages/dashboard/namespace-members'),
const MyStarsPage = lazyRouteComponent(() => import('@/pages/dashboard/stars'), 'MyStarsPage') 'NamespaceMembersPage',
const TokensPage = lazyRouteComponent(() => import('@/pages/dashboard/tokens'), 'TokensPage') )
const DeviceAuthPage = lazyRouteComponent(() => import('@/pages/device'), 'DeviceAuthPage') const NamespaceReviewsPage = createLazyRouteComponent(
const SecuritySettingsPage = lazyRouteComponent(() => import('@/pages/settings/security'), 'SecuritySettingsPage') () => import('@/pages/dashboard/namespace-reviews'),
const AccountSettingsPage = lazyRouteComponent(() => import('@/pages/settings/accounts'), 'AccountSettingsPage') 'NamespaceReviewsPage',
const AdminUsersPage = lazyRouteComponent(() => import('@/pages/admin/users'), 'AdminUsersPage') )
const AuditLogPage = lazyRouteComponent(() => import('@/pages/admin/audit-log'), 'AuditLogPage') const ReviewsPage = createLazyRouteComponent(() => import('@/pages/dashboard/reviews'), 'ReviewsPage')
const ReviewDetailPage = createLazyRouteComponent(
() => import('@/pages/dashboard/review-detail'),
'ReviewDetailPage',
)
const PromotionsPage = createLazyRouteComponent(
() => import('@/pages/dashboard/promotions'),
'PromotionsPage',
)
const MyStarsPage = createLazyRouteComponent(() => import('@/pages/dashboard/stars'), 'MyStarsPage')
const TokensPage = createLazyRouteComponent(() => import('@/pages/dashboard/tokens'), 'TokensPage')
const DeviceAuthPage = createLazyRouteComponent(() => import('@/pages/device'), 'DeviceAuthPage')
const SecuritySettingsPage = createLazyRouteComponent(
() => import('@/pages/settings/security'),
'SecuritySettingsPage',
)
const AccountSettingsPage = createLazyRouteComponent(
() => import('@/pages/settings/accounts'),
'AccountSettingsPage',
)
const AdminUsersPage = createLazyRouteComponent(() => import('@/pages/admin/users'), 'AdminUsersPage')
const AuditLogPage = createLazyRouteComponent(() => import('@/pages/admin/audit-log'), 'AuditLogPage')
const rootRoute = createRootRoute({ const rootRoute = createRootRoute({
component: Layout, component: Layout,

View file

@ -2,51 +2,21 @@ import ReactMarkdown from 'react-markdown'
import rehypeHighlight from 'rehype-highlight' import rehypeHighlight from 'rehype-highlight'
import rehypeSanitize from 'rehype-sanitize' import rehypeSanitize from 'rehype-sanitize'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import { cn } from '@/shared/lib/utils'
interface MarkdownRendererProps { interface MarkdownRendererProps {
content: string content: string
className?: string className?: string
} }
function stripFrontmatter(content: string) {
return content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '')
}
export function MarkdownRenderer({ content, className }: MarkdownRendererProps) { export function MarkdownRenderer({ content, className }: MarkdownRendererProps) {
const markdown = stripFrontmatter(content).trim() const containerClassName = [className, 'prose prose-sm max-w-none dark:prose-invert']
.filter(Boolean)
.join(' ')
return ( return (
<div <div className={containerClassName}>
className={cn( <ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeSanitize, rehypeHighlight]}>
'max-w-none text-sm leading-7 text-foreground', {content}
'[&_a]:text-primary [&_a]:underline [&_a]:underline-offset-4 hover:[&_a]:text-primary/80',
'[&_blockquote]:border-l-4 [&_blockquote]:border-border [&_blockquote]:pl-4 [&_blockquote]:italic [&_blockquote]:text-muted-foreground',
'[&_code]:rounded-md [&_code]:bg-muted/70 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-[0.9em]',
'[&_h1]:mt-0 [&_h1]:mb-4 [&_h1]:text-3xl [&_h1]:font-bold [&_h1]:font-heading [&_h1]:leading-tight',
'[&_h2]:mt-10 [&_h2]:mb-4 [&_h2]:border-b [&_h2]:border-border/60 [&_h2]:pb-2 [&_h2]:text-2xl [&_h2]:font-semibold [&_h2]:font-heading',
'[&_h3]:mt-8 [&_h3]:mb-3 [&_h3]:text-xl [&_h3]:font-semibold [&_h3]:font-heading',
'[&_h4]:mt-6 [&_h4]:mb-2 [&_h4]:text-lg [&_h4]:font-semibold [&_h4]:font-heading',
'[&_hr]:my-8 [&_hr]:border-border/60',
'[&_img]:rounded-xl [&_img]:border [&_img]:border-border/60',
'[&_li]:my-1.5',
'[&_ol]:my-4 [&_ol]:list-decimal [&_ol]:pl-6',
'[&_p]:my-4',
'[&_pre]:my-5 [&_pre]:overflow-x-auto [&_pre]:rounded-xl [&_pre]:border [&_pre]:border-border/60 [&_pre]:bg-slate-950 [&_pre]:p-4 [&_pre]:text-sm [&_pre]:text-slate-100',
'[&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:text-inherit',
'[&_table]:my-6 [&_table]:w-full [&_table]:border-collapse [&_table]:overflow-hidden',
'[&_tbody_tr]:border-t [&_tbody_tr]:border-border/60',
'[&_td]:border [&_td]:border-border/60 [&_td]:px-3 [&_td]:py-2 [&_td]:align-top',
'[&_th]:border [&_th]:border-border/60 [&_th]:bg-muted/50 [&_th]:px-3 [&_th]:py-2 [&_th]:text-left [&_th]:font-semibold',
'[&_ul]:my-4 [&_ul]:list-disc [&_ul]:pl-6',
className,
)}
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeSanitize, rehypeHighlight]}
>
{markdown}
</ReactMarkdown> </ReactMarkdown>
</div> </div>
) )