mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-27 11:14:59 +00:00
Merge pull request #2 from iflytek/feature/project-review
review: fix device auth, review permissions, and publish state flow
This commit is contained in:
commit
1407e335c2
59 changed files with 2983 additions and 1084 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -67,4 +67,5 @@ __pycache__/
|
|||
|
||||
# Superpowers (AI planning artifacts)
|
||||
docs/superpowers/
|
||||
CLAUDE.md
|
||||
docs/review/
|
||||
CLAUDE.md
|
||||
|
|
|
|||
|
|
@ -6,7 +6,18 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|||
SERVER_DIR="$ROOT_DIR/server"
|
||||
WEB_DIR="$ROOT_DIR/web"
|
||||
API_LOG="${TMPDIR:-/tmp}/skillhub-openapi-check.log"
|
||||
BUILD_LOG="${TMPDIR:-/tmp}/skillhub-openapi-build.log"
|
||||
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() {
|
||||
if [[ -n "$SERVER_PID" ]] && kill -0 "$SERVER_PID" 2>/dev/null; then
|
||||
|
|
@ -21,6 +32,15 @@ trap cleanup EXIT
|
|||
cd "$ROOT_DIR"
|
||||
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"
|
||||
SPRING_PROFILES_ACTIVE=local ./mvnw -pl skillhub-app spring-boot:run
|
||||
|
|
@ -28,14 +48,22 @@ docker compose up -d --wait postgres redis
|
|||
SERVER_PID=$!
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
print_log_tail "$API_LOG"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
|
|
|||
|
|
@ -2,28 +2,29 @@ package com.iflytek.skillhub.compat;
|
|||
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
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.domain.audit.AuditLogService;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
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.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 java.io.IOException;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.slf4j.MDC;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/compat/v1")
|
||||
|
|
@ -51,18 +52,31 @@ public class ClawHubCompatController {
|
|||
}
|
||||
|
||||
@GetMapping("/search")
|
||||
public ClawHubSearchResponse search(@RequestParam String q,
|
||||
@RequestAttribute(value = "userId", required = false) String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
|
||||
var result = skillSearchAppService.search(q, null, "relevance", 0, 20, userId, userNsRoles != null ? userNsRoles : Map.of());
|
||||
return new ClawHubSearchResponse(result.items().stream()
|
||||
.map(item -> new ClawHubSkillItem(
|
||||
mapper.toCanonical(item.namespace(), item.slug()),
|
||||
item.summary(),
|
||||
item.latestVersion(),
|
||||
item.starCount()
|
||||
))
|
||||
.toList());
|
||||
public ClawHubSearchResponse search(
|
||||
@RequestParam String q,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int limit,
|
||||
@RequestAttribute(value = "userId", required = false) String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
|
||||
SkillSearchAppService.SearchResponse response = skillSearchAppService.search(
|
||||
q,
|
||||
null,
|
||||
q == null || q.isBlank() ? "newest" : "relevance",
|
||||
page,
|
||||
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}")
|
||||
|
|
@ -72,14 +86,14 @@ public class ClawHubCompatController {
|
|||
@RequestAttribute(value = "userId", required = false) String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
|
||||
SkillCoordinate coord = mapper.fromCanonical(canonicalSlug);
|
||||
var resolved = skillQueryService.resolveVersion(
|
||||
coord.namespace(),
|
||||
coord.slug(),
|
||||
"latest".equals(version) ? null : version,
|
||||
"latest".equals(version) ? "latest" : null,
|
||||
null,
|
||||
userId,
|
||||
userNsRoles != null ? userNsRoles : Map.of()
|
||||
SkillQueryService.ResolvedVersionDTO resolved = skillQueryService.resolveVersion(
|
||||
coord.namespace(),
|
||||
coord.slug(),
|
||||
"latest".equals(version) ? null : version,
|
||||
"latest".equals(version) ? "latest" : null,
|
||||
null,
|
||||
userId,
|
||||
userNsRoles != null ? userNsRoles : Map.of()
|
||||
);
|
||||
return new ClawHubResolveResponse(
|
||||
canonicalSlug,
|
||||
|
|
@ -93,39 +107,39 @@ public class ClawHubCompatController {
|
|||
@RequestParam(defaultValue = "latest") String version) {
|
||||
SkillCoordinate coord = mapper.fromCanonical(canonicalSlug);
|
||||
String location = "latest".equals(version)
|
||||
? "/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() + "/download"
|
||||
: "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/versions/" + version + "/download";
|
||||
return ResponseEntity.status(HttpStatus.FOUND)
|
||||
.header(HttpHeaders.LOCATION, location)
|
||||
.build();
|
||||
.header(HttpHeaders.LOCATION, location)
|
||||
.build();
|
||||
}
|
||||
|
||||
@PostMapping("/publish")
|
||||
public ClawHubPublishResponse publish(@RequestParam("file") MultipartFile file,
|
||||
@RequestParam("namespace") String namespace,
|
||||
@AuthenticationPrincipal PlatformPrincipal principal,
|
||||
jakarta.servlet.http.HttpServletRequest request) throws IOException {
|
||||
var result = skillPublishService.publishFromEntries(
|
||||
namespace,
|
||||
zipPackageExtractor.extract(file),
|
||||
principal.userId(),
|
||||
SkillVisibility.PUBLIC,
|
||||
principal.platformRoles()
|
||||
HttpServletRequest request) throws IOException {
|
||||
SkillPublishService.PublishResult result = skillPublishService.publishFromEntries(
|
||||
namespace,
|
||||
zipPackageExtractor.extract(file),
|
||||
principal.userId(),
|
||||
SkillVisibility.PUBLIC,
|
||||
principal.platformRoles()
|
||||
);
|
||||
auditLogService.record(
|
||||
principal.userId(),
|
||||
"COMPAT_PUBLISH",
|
||||
"SKILL_VERSION",
|
||||
result.version().getId(),
|
||||
MDC.get("requestId"),
|
||||
request.getRemoteAddr(),
|
||||
request.getHeader("User-Agent"),
|
||||
"{\"namespace\":\"" + namespace + "\"}"
|
||||
principal.userId(),
|
||||
"COMPAT_PUBLISH",
|
||||
"SKILL_VERSION",
|
||||
result.version().getId(),
|
||||
MDC.get("requestId"),
|
||||
request.getRemoteAddr(),
|
||||
request.getHeader("User-Agent"),
|
||||
"{\"namespace\":\"" + namespace + "\"}"
|
||||
);
|
||||
return new ClawHubPublishResponse(
|
||||
mapper.toCanonical(namespace, result.slug()),
|
||||
result.version().getVersion(),
|
||||
result.version().getStatus().name()
|
||||
mapper.toCanonical(namespace, result.slug()),
|
||||
result.version().getVersion(),
|
||||
result.version().getStatus().name()
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,39 +1,41 @@
|
|||
package com.iflytek.skillhub.controller;
|
||||
|
||||
import com.iflytek.skillhub.controller.support.SkillPackageArchiveExtractor;
|
||||
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.SkillPackageValidator;
|
||||
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.ApiResponseFactory;
|
||||
import com.iflytek.skillhub.dto.CliWhoamiResponse;
|
||||
import com.iflytek.skillhub.dto.ResolveVersionResponse;
|
||||
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 com.iflytek.skillhub.exception.UnauthorizedException;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/cli")
|
||||
public class CliController extends BaseApiController {
|
||||
|
||||
private final SkillPackageValidator skillPackageValidator;
|
||||
private final SkillPackageArchiveExtractor skillPackageArchiveExtractor;
|
||||
private final SkillQueryService skillQueryService;
|
||||
|
||||
public CliController(ApiResponseFactory responseFactory,
|
||||
SkillPackageValidator skillPackageValidator,
|
||||
SkillPackageArchiveExtractor skillPackageArchiveExtractor,
|
||||
SkillQueryService skillQueryService) {
|
||||
super(responseFactory);
|
||||
this.skillPackageValidator = skillPackageValidator;
|
||||
this.skillPackageArchiveExtractor = skillPackageArchiveExtractor;
|
||||
this.skillQueryService = skillQueryService;
|
||||
}
|
||||
|
||||
|
|
@ -48,7 +50,18 @@ public class CliController extends BaseApiController {
|
|||
|
||||
@PostMapping("/check")
|
||||
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);
|
||||
|
||||
SkillCheckResponse response = new SkillCheckResponse(
|
||||
|
|
@ -68,56 +81,25 @@ public class CliController extends BaseApiController {
|
|||
@RequestParam(required = false) String tag,
|
||||
@RequestParam(required = false) String hash,
|
||||
@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(
|
||||
namespace,
|
||||
slug,
|
||||
version,
|
||||
tag,
|
||||
hash,
|
||||
userId,
|
||||
userNsRoles != null ? userNsRoles : java.util.Map.of()
|
||||
namespace,
|
||||
slug,
|
||||
version,
|
||||
tag,
|
||||
hash,
|
||||
userId,
|
||||
userNsRoles != null ? userNsRoles : Map.of()
|
||||
);
|
||||
return ok("response.success.read", new ResolveVersionResponse(
|
||||
resolved.skillId(),
|
||||
resolved.namespace(),
|
||||
resolved.slug(),
|
||||
resolved.version(),
|
||||
resolved.versionId(),
|
||||
resolved.fingerprint(),
|
||||
resolved.matched(),
|
||||
resolved.downloadUrl()
|
||||
resolved.skillId(),
|
||||
resolved.namespace(),
|
||||
resolved.slug(),
|
||||
resolved.version(),
|
||||
resolved.versionId(),
|
||||
resolved.fingerprint(),
|
||||
resolved.matched(),
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@ import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
|||
import com.iflytek.skillhub.auth.token.ApiTokenService;
|
||||
import com.iflytek.skillhub.dto.ApiResponse;
|
||||
import com.iflytek.skillhub.dto.ApiResponseFactory;
|
||||
import com.iflytek.skillhub.dto.MessageResponse;
|
||||
import com.iflytek.skillhub.dto.TokenCreateRequest;
|
||||
import com.iflytek.skillhub.dto.TokenCreateResponse;
|
||||
import com.iflytek.skillhub.dto.TokenSummaryResponse;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
|
|
@ -59,10 +59,10 @@ public class TokenController extends BaseApiController {
|
|||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ApiResponse<MessageResponse> revoke(
|
||||
public ResponseEntity<Void> revoke(
|
||||
@AuthenticationPrincipal PlatformPrincipal principal,
|
||||
@PathVariable Long id) {
|
||||
apiTokenService.revokeToken(id, principal.userId());
|
||||
return ok("response.success.revoked", new MessageResponse("Token revoked"));
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import com.iflytek.skillhub.dto.ApiResponse;
|
|||
import com.iflytek.skillhub.dto.ApiResponseFactory;
|
||||
import com.iflytek.skillhub.dto.AuditLogItemResponse;
|
||||
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.web.bind.annotation.*;
|
||||
|
||||
|
|
@ -13,12 +13,12 @@ import org.springframework.web.bind.annotation.*;
|
|||
@RequestMapping("/api/v1/admin/audit-logs")
|
||||
public class AuditLogController extends BaseApiController {
|
||||
|
||||
private final AuditLogQueryService auditLogQueryService;
|
||||
private final AdminAuditLogAppService adminAuditLogAppService;
|
||||
|
||||
public AuditLogController(ApiResponseFactory responseFactory,
|
||||
AuditLogQueryService auditLogQueryService) {
|
||||
public AuditLogController(AdminAuditLogAppService adminAuditLogAppService,
|
||||
ApiResponseFactory responseFactory) {
|
||||
super(responseFactory);
|
||||
this.auditLogQueryService = auditLogQueryService;
|
||||
this.adminAuditLogAppService = adminAuditLogAppService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
|
|
@ -28,16 +28,6 @@ public class AuditLogController extends BaseApiController {
|
|||
@RequestParam(defaultValue = "20") int size,
|
||||
@RequestParam(required = false) String userId,
|
||||
@RequestParam(required = false) String action) {
|
||||
var logs = auditLogQueryService.list(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));
|
||||
return ok("response.success.read", adminAuditLogAppService.listAuditLogs(page, size, userId, action));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
package com.iflytek.skillhub.controller.admin;
|
||||
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.controller.BaseApiController;
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.dto.AdminUserMutationResponse;
|
||||
import com.iflytek.skillhub.dto.AdminUserRoleUpdateRequest;
|
||||
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.ApiResponseFactory;
|
||||
import com.iflytek.skillhub.dto.PageResponse;
|
||||
import com.iflytek.skillhub.service.AdminUserManagementService;
|
||||
import com.iflytek.skillhub.service.AdminUserAppService;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
|
|
@ -19,12 +19,12 @@ import org.springframework.web.bind.annotation.*;
|
|||
@RequestMapping("/api/v1/admin/users")
|
||||
public class UserManagementController extends BaseApiController {
|
||||
|
||||
private final AdminUserManagementService adminUserManagementService;
|
||||
private final AdminUserAppService adminUserAppService;
|
||||
|
||||
public UserManagementController(ApiResponseFactory responseFactory,
|
||||
AdminUserManagementService adminUserManagementService) {
|
||||
public UserManagementController(AdminUserAppService adminUserAppService,
|
||||
ApiResponseFactory responseFactory) {
|
||||
super(responseFactory);
|
||||
this.adminUserManagementService = adminUserManagementService;
|
||||
this.adminUserAppService = adminUserAppService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
|
|
@ -34,17 +34,17 @@ public class UserManagementController extends BaseApiController {
|
|||
@RequestParam(required = false) String status,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@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")
|
||||
@PreAuthorize("hasAnyRole('USER_ADMIN', 'SUPER_ADMIN')")
|
||||
public ApiResponse<AdminUserMutationResponse> updateUserRole(
|
||||
@PathVariable String userId,
|
||||
@Valid @RequestBody AdminUserRoleUpdateRequest request,
|
||||
@AuthenticationPrincipal PlatformPrincipal principal) {
|
||||
AdminUserSummaryResponse user = adminUserManagementService.updateUserRole(userId, request.role(), principal);
|
||||
return ok("response.success.updated", new AdminUserMutationResponse(user.userId(), request.role(), user.status()));
|
||||
@AuthenticationPrincipal PlatformPrincipal principal,
|
||||
@Valid @RequestBody AdminUserRoleUpdateRequest request) {
|
||||
return ok("response.success.updated",
|
||||
adminUserAppService.updateUserRole(userId, request.role(), principal.platformRoles()));
|
||||
}
|
||||
|
||||
@PutMapping("/{userId}/status")
|
||||
|
|
@ -52,28 +52,24 @@ public class UserManagementController extends BaseApiController {
|
|||
public ApiResponse<AdminUserMutationResponse> updateUserStatus(
|
||||
@PathVariable String userId,
|
||||
@Valid @RequestBody AdminUserStatusUpdateRequest request) {
|
||||
AdminUserSummaryResponse user = adminUserManagementService.updateUserStatus(userId, request.status());
|
||||
return ok("response.success.updated", new AdminUserMutationResponse(user.userId(), null, user.status()));
|
||||
return ok("response.success.updated", adminUserAppService.updateUserStatus(userId, request.status()));
|
||||
}
|
||||
|
||||
@PostMapping("/{userId}/approve")
|
||||
@PreAuthorize("hasAnyRole('USER_ADMIN', 'SUPER_ADMIN')")
|
||||
public ApiResponse<AdminUserMutationResponse> approveUser(@PathVariable String userId) {
|
||||
AdminUserSummaryResponse user = adminUserManagementService.approveUser(userId);
|
||||
return ok("response.success.updated", new AdminUserMutationResponse(user.userId(), null, user.status()));
|
||||
return ok("response.success.updated", adminUserAppService.updateUserStatus(userId, "ACTIVE"));
|
||||
}
|
||||
|
||||
@PostMapping("/{userId}/disable")
|
||||
@PreAuthorize("hasAnyRole('USER_ADMIN', 'SUPER_ADMIN')")
|
||||
public ApiResponse<AdminUserMutationResponse> disableUser(@PathVariable String userId) {
|
||||
AdminUserSummaryResponse user = adminUserManagementService.disableUser(userId);
|
||||
return ok("response.success.updated", new AdminUserMutationResponse(user.userId(), null, user.status()));
|
||||
return ok("response.success.updated", adminUserAppService.updateUserStatus(userId, "DISABLED"));
|
||||
}
|
||||
|
||||
@PostMapping("/{userId}/enable")
|
||||
@PreAuthorize("hasAnyRole('USER_ADMIN', 'SUPER_ADMIN')")
|
||||
public ApiResponse<AdminUserMutationResponse> enableUser(@PathVariable String userId) {
|
||||
AdminUserSummaryResponse user = adminUserManagementService.enableUser(userId);
|
||||
return ok("response.success.updated", new AdminUserMutationResponse(user.userId(), null, user.status()));
|
||||
return ok("response.success.updated", adminUserAppService.updateUserStatus(userId, "ACTIVE"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,9 @@ package com.iflytek.skillhub.controller.cli;
|
|||
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
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.shared.exception.DomainBadRequestException;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
|
||||
import com.iflytek.skillhub.domain.skill.validation.PackageEntry;
|
||||
|
|
@ -26,18 +27,18 @@ import java.util.List;
|
|||
public class CliPublishController extends BaseApiController {
|
||||
|
||||
private final SkillPublishService skillPublishService;
|
||||
private final ZipPackageExtractor zipPackageExtractor;
|
||||
private final SkillPackageArchiveExtractor skillPackageArchiveExtractor;
|
||||
private final SkillHubMetrics skillHubMetrics;
|
||||
private final AuditLogService auditLogService;
|
||||
|
||||
public CliPublishController(SkillPublishService skillPublishService,
|
||||
ZipPackageExtractor zipPackageExtractor,
|
||||
SkillPackageArchiveExtractor skillPackageArchiveExtractor,
|
||||
ApiResponseFactory responseFactory,
|
||||
SkillHubMetrics skillHubMetrics,
|
||||
AuditLogService auditLogService) {
|
||||
super(responseFactory);
|
||||
this.skillPublishService = skillPublishService;
|
||||
this.zipPackageExtractor = zipPackageExtractor;
|
||||
this.skillPackageArchiveExtractor = skillPackageArchiveExtractor;
|
||||
this.skillHubMetrics = skillHubMetrics;
|
||||
this.auditLogService = auditLogService;
|
||||
}
|
||||
|
|
@ -53,7 +54,12 @@ public class CliPublishController extends BaseApiController {
|
|||
|
||||
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(
|
||||
namespace,
|
||||
|
|
|
|||
|
|
@ -18,15 +18,27 @@ import com.iflytek.skillhub.domain.skill.SkillVersion;
|
|||
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
|
||||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
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 org.slf4j.MDC;
|
||||
import org.springframework.data.domain.Page;
|
||||
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.Set;
|
||||
import org.slf4j.MDC;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/promotions")
|
||||
|
|
@ -62,56 +74,57 @@ public class PromotionController extends BaseApiController {
|
|||
}
|
||||
|
||||
@PostMapping
|
||||
public ApiResponse<PromotionResponseDto> submitPromotion(
|
||||
@RequestBody PromotionRequestDto request,
|
||||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
|
||||
HttpServletRequest httpRequest) {
|
||||
public ApiResponse<PromotionResponseDto> submitPromotion(@RequestBody PromotionRequestDto request,
|
||||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
|
||||
HttpServletRequest httpRequest) {
|
||||
PromotionRequest promotion = promotionService.submitPromotion(
|
||||
request.sourceSkillId(), request.sourceVersionId(),
|
||||
request.targetNamespaceId(), userId,
|
||||
request.sourceSkillId(),
|
||||
request.sourceVersionId(),
|
||||
request.targetNamespaceId(),
|
||||
userId,
|
||||
userNsRoles != null ? userNsRoles : Map.of(),
|
||||
rbacService.getUserRoleCodes(userId));
|
||||
recordAudit("PROMOTION_SUBMIT", userId, promotion.getId(), httpRequest,
|
||||
"{\"sourceSkillId\":" + request.sourceSkillId() + ",\"sourceVersionId\":" + request.sourceVersionId() + "}");
|
||||
rbacService.getUserRoleCodes(userId)
|
||||
);
|
||||
recordAudit(
|
||||
"PROMOTION_SUBMIT",
|
||||
userId,
|
||||
promotion.getId(),
|
||||
httpRequest,
|
||||
"{\"sourceSkillId\":" + request.sourceSkillId() + ",\"sourceVersionId\":" + request.sourceVersionId() + "}"
|
||||
);
|
||||
return ok("response.success.created", toResponse(promotion));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/approve")
|
||||
public ApiResponse<PromotionResponseDto> approvePromotion(
|
||||
@PathVariable Long id,
|
||||
@RequestBody(required = false) PromotionActionRequest request,
|
||||
@RequestAttribute("userId") String userId,
|
||||
HttpServletRequest httpRequest) {
|
||||
public ApiResponse<PromotionResponseDto> approvePromotion(@PathVariable Long id,
|
||||
@RequestBody(required = false) PromotionActionRequest request,
|
||||
@RequestAttribute("userId") String userId,
|
||||
HttpServletRequest httpRequest) {
|
||||
String comment = request != null ? request.comment() : null;
|
||||
Set<String> platformRoles = rbacService.getUserRoleCodes(userId);
|
||||
PromotionRequest promotion = promotionService.approvePromotion(id, userId, comment, platformRoles);
|
||||
PromotionRequest promotion = promotionService.approvePromotion(id, userId, comment, rbacService.getUserRoleCodes(userId));
|
||||
recordAudit("PROMOTION_APPROVE", userId, promotion.getId(), httpRequest, detailWithComment(comment));
|
||||
return ok("response.success.updated", toResponse(promotion));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/reject")
|
||||
public ApiResponse<PromotionResponseDto> rejectPromotion(
|
||||
@PathVariable Long id,
|
||||
@RequestBody(required = false) PromotionActionRequest request,
|
||||
@RequestAttribute("userId") String userId,
|
||||
HttpServletRequest httpRequest) {
|
||||
public ApiResponse<PromotionResponseDto> rejectPromotion(@PathVariable Long id,
|
||||
@RequestBody(required = false) PromotionActionRequest request,
|
||||
@RequestAttribute("userId") String userId,
|
||||
HttpServletRequest httpRequest) {
|
||||
String comment = request != null ? request.comment() : null;
|
||||
Set<String> platformRoles = rbacService.getUserRoleCodes(userId);
|
||||
PromotionRequest promotion = promotionService.rejectPromotion(id, userId, comment, platformRoles);
|
||||
PromotionRequest promotion = promotionService.rejectPromotion(id, userId, comment, rbacService.getUserRoleCodes(userId));
|
||||
recordAudit("PROMOTION_REJECT", userId, promotion.getId(), httpRequest, detailWithComment(comment));
|
||||
return ok("response.success.updated", toResponse(promotion));
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ApiResponse<PageResponse<PromotionResponseDto>> listPromotions(
|
||||
@RequestParam(defaultValue = "PENDING") String status,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size,
|
||||
@RequestAttribute("userId") String userId) {
|
||||
public ApiResponse<PageResponse<PromotionResponseDto>> listPromotions(@RequestParam(defaultValue = "PENDING") String status,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size,
|
||||
@RequestAttribute("userId") String userId) {
|
||||
Set<String> platformRoles = rbacService.getUserRoleCodes(userId);
|
||||
boolean hasAdminRole = platformRoles.contains("SKILL_ADMIN") || platformRoles.contains("SUPER_ADMIN");
|
||||
if (!hasAdminRole) {
|
||||
if (!platformRoles.contains("SKILL_ADMIN") && !platformRoles.contains("SUPER_ADMIN")) {
|
||||
throw new DomainForbiddenException("promotion.no_permission");
|
||||
}
|
||||
ReviewTaskStatus reviewStatus = ReviewTaskStatus.valueOf(status.toUpperCase());
|
||||
|
|
@ -120,13 +133,11 @@ public class PromotionController extends BaseApiController {
|
|||
}
|
||||
|
||||
@GetMapping("/pending")
|
||||
public ApiResponse<PageResponse<PromotionResponseDto>> listPendingPromotions(
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size,
|
||||
@RequestAttribute("userId") String userId) {
|
||||
public ApiResponse<PageResponse<PromotionResponseDto>> listPendingPromotions(@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size,
|
||||
@RequestAttribute("userId") String userId) {
|
||||
Set<String> platformRoles = rbacService.getUserRoleCodes(userId);
|
||||
boolean hasAdminRole = platformRoles.contains("SKILL_ADMIN") || platformRoles.contains("SUPER_ADMIN");
|
||||
if (!hasAdminRole) {
|
||||
if (!platformRoles.contains("SKILL_ADMIN") && !platformRoles.contains("SUPER_ADMIN")) {
|
||||
throw new DomainForbiddenException("promotion.no_permission");
|
||||
}
|
||||
Page<PromotionRequest> requests = promotionRequestRepository.findByStatus(
|
||||
|
|
@ -145,40 +156,39 @@ public class PromotionController extends BaseApiController {
|
|||
return ok("response.success.read", toResponse(promotion));
|
||||
}
|
||||
|
||||
private PromotionResponseDto toResponse(PromotionRequest req) {
|
||||
Skill sourceSkill = skillRepository.findById(req.getSourceSkillId())
|
||||
.orElseThrow(() -> new DomainNotFoundException("skill.not_found", req.getSourceSkillId()));
|
||||
SkillVersion sourceVersion = skillVersionRepository.findById(req.getSourceVersionId())
|
||||
.orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", req.getSourceVersionId()));
|
||||
Namespace sourceNs = namespaceRepository.findById(sourceSkill.getNamespaceId())
|
||||
private PromotionResponseDto toResponse(PromotionRequest request) {
|
||||
Skill sourceSkill = skillRepository.findById(request.getSourceSkillId())
|
||||
.orElseThrow(() -> new DomainNotFoundException("skill.not_found", request.getSourceSkillId()));
|
||||
SkillVersion sourceVersion = skillVersionRepository.findById(request.getSourceVersionId())
|
||||
.orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", request.getSourceVersionId()));
|
||||
Namespace sourceNamespace = namespaceRepository.findById(sourceSkill.getNamespaceId())
|
||||
.orElseThrow(() -> new DomainNotFoundException("namespace.not_found", sourceSkill.getNamespaceId()));
|
||||
Namespace targetNs = namespaceRepository.findById(req.getTargetNamespaceId())
|
||||
.orElseThrow(() -> new DomainNotFoundException("namespace.not_found", req.getTargetNamespaceId()));
|
||||
Namespace targetNamespace = namespaceRepository.findById(request.getTargetNamespaceId())
|
||||
.orElseThrow(() -> new DomainNotFoundException("namespace.not_found", request.getTargetNamespaceId()));
|
||||
|
||||
String submittedByName = userAccountRepository.findById(req.getSubmittedBy())
|
||||
.map(UserAccount::getDisplayName).orElse(null);
|
||||
|
||||
String reviewedByName = req.getReviewedBy() != null
|
||||
? userAccountRepository.findById(req.getReviewedBy())
|
||||
.map(UserAccount::getDisplayName).orElse(null)
|
||||
String submittedByName = userAccountRepository.findById(request.getSubmittedBy())
|
||||
.map(UserAccount::getDisplayName)
|
||||
.orElse(null);
|
||||
String reviewedByName = request.getReviewedBy() != null
|
||||
? userAccountRepository.findById(request.getReviewedBy()).map(UserAccount::getDisplayName).orElse(null)
|
||||
: null;
|
||||
|
||||
return new PromotionResponseDto(
|
||||
req.getId(),
|
||||
req.getSourceSkillId(),
|
||||
sourceNs.getSlug(),
|
||||
request.getId(),
|
||||
request.getSourceSkillId(),
|
||||
sourceNamespace.getSlug(),
|
||||
sourceSkill.getSlug(),
|
||||
sourceVersion.getVersion(),
|
||||
targetNs.getSlug(),
|
||||
req.getTargetSkillId(),
|
||||
req.getStatus().name(),
|
||||
req.getSubmittedBy(),
|
||||
targetNamespace.getSlug(),
|
||||
request.getTargetSkillId(),
|
||||
request.getStatus().name(),
|
||||
request.getSubmittedBy(),
|
||||
submittedByName,
|
||||
req.getReviewedBy(),
|
||||
request.getReviewedBy(),
|
||||
reviewedByName,
|
||||
req.getReviewComment(),
|
||||
req.getSubmittedAt(),
|
||||
req.getReviewedAt()
|
||||
request.getReviewComment(),
|
||||
request.getSubmittedAt(),
|
||||
request.getReviewedAt()
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,16 +18,28 @@ import com.iflytek.skillhub.domain.skill.SkillVersion;
|
|||
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
|
||||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
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 org.slf4j.MDC;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
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.Set;
|
||||
import org.slf4j.MDC;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/reviews")
|
||||
|
|
@ -63,11 +75,10 @@ public class ReviewController extends BaseApiController {
|
|||
}
|
||||
|
||||
@PostMapping
|
||||
public ApiResponse<ReviewTaskResponse> submitReview(
|
||||
@RequestBody ReviewTaskRequest request,
|
||||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
|
||||
HttpServletRequest httpRequest) {
|
||||
public ApiResponse<ReviewTaskResponse> submitReview(@RequestBody ReviewTaskRequest request,
|
||||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
|
||||
HttpServletRequest httpRequest) {
|
||||
ReviewTask task = reviewService.submitReview(
|
||||
request.skillVersionId(),
|
||||
userId,
|
||||
|
|
@ -79,54 +90,59 @@ public class ReviewController extends BaseApiController {
|
|||
}
|
||||
|
||||
@PostMapping("/{id}/approve")
|
||||
public ApiResponse<ReviewTaskResponse> approveReview(
|
||||
@PathVariable Long id,
|
||||
@RequestBody(required = false) ReviewActionRequest request,
|
||||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
|
||||
HttpServletRequest httpRequest) {
|
||||
public ApiResponse<ReviewTaskResponse> approveReview(@PathVariable Long id,
|
||||
@RequestBody(required = false) ReviewActionRequest request,
|
||||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
|
||||
HttpServletRequest httpRequest) {
|
||||
String comment = request != null ? request.comment() : null;
|
||||
Set<String> platformRoles = rbacService.getUserRoleCodes(userId);
|
||||
ReviewTask task = reviewService.approveReview(id, userId, comment,
|
||||
userNsRoles != null ? userNsRoles : Map.of(), platformRoles);
|
||||
ReviewTask task = reviewService.approveReview(
|
||||
id,
|
||||
userId,
|
||||
comment,
|
||||
userNsRoles != null ? userNsRoles : Map.of(),
|
||||
rbacService.getUserRoleCodes(userId)
|
||||
);
|
||||
recordAudit("REVIEW_APPROVE", userId, task.getId(), httpRequest, detailWithComment(comment));
|
||||
return ok("response.success.updated", toResponse(task));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/reject")
|
||||
public ApiResponse<ReviewTaskResponse> rejectReview(
|
||||
@PathVariable Long id,
|
||||
@RequestBody(required = false) ReviewActionRequest request,
|
||||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
|
||||
HttpServletRequest httpRequest) {
|
||||
public ApiResponse<ReviewTaskResponse> rejectReview(@PathVariable Long id,
|
||||
@RequestBody(required = false) ReviewActionRequest request,
|
||||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
|
||||
HttpServletRequest httpRequest) {
|
||||
String comment = request != null ? request.comment() : null;
|
||||
Set<String> platformRoles = rbacService.getUserRoleCodes(userId);
|
||||
ReviewTask task = reviewService.rejectReview(id, userId, comment,
|
||||
userNsRoles != null ? userNsRoles : Map.of(), platformRoles);
|
||||
ReviewTask task = reviewService.rejectReview(
|
||||
id,
|
||||
userId,
|
||||
comment,
|
||||
userNsRoles != null ? userNsRoles : Map.of(),
|
||||
rbacService.getUserRoleCodes(userId)
|
||||
);
|
||||
recordAudit("REVIEW_REJECT", userId, task.getId(), httpRequest, detailWithComment(comment));
|
||||
return ok("response.success.updated", toResponse(task));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/withdraw")
|
||||
public ApiResponse<Void> withdrawReview(
|
||||
@PathVariable Long id,
|
||||
@RequestAttribute("userId") String userId,
|
||||
HttpServletRequest httpRequest) {
|
||||
ReviewTask task = reviewTaskRepository.findById(id).orElseThrow();
|
||||
public ApiResponse<Void> withdrawReview(@PathVariable Long id,
|
||||
@RequestAttribute("userId") String userId,
|
||||
HttpServletRequest httpRequest) {
|
||||
ReviewTask task = reviewTaskRepository.findById(id)
|
||||
.orElseThrow(() -> new DomainNotFoundException("review_task.not_found", id));
|
||||
reviewService.withdrawReview(task.getSkillVersionId(), userId);
|
||||
recordAudit("REVIEW_WITHDRAW", userId, id, httpRequest, "{\"skillVersionId\":" + task.getSkillVersionId() + "}");
|
||||
return ok("response.success.updated", null);
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ApiResponse<PageResponse<ReviewTaskResponse>> listReviews(
|
||||
@RequestParam String status,
|
||||
@RequestParam(required = false) Long namespaceId,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size,
|
||||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
|
||||
public ApiResponse<PageResponse<ReviewTaskResponse>> listReviews(@RequestParam String status,
|
||||
@RequestParam(required = false) Long namespaceId,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size,
|
||||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
|
||||
ReviewTaskStatus reviewStatus = ReviewTaskStatus.valueOf(status.toUpperCase());
|
||||
Map<Long, NamespaceRole> namespaceRoles = userNsRoles != null ? userNsRoles : Map.of();
|
||||
|
||||
|
|
@ -135,7 +151,12 @@ public class ReviewController extends BaseApiController {
|
|||
Namespace namespace = namespaceRepository.findById(namespaceId)
|
||||
.orElseThrow(() -> new DomainNotFoundException("namespace.not_found", namespaceId));
|
||||
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");
|
||||
}
|
||||
tasks = reviewTaskRepository.findByNamespaceIdAndStatus(namespaceId, reviewStatus, PageRequest.of(page, size));
|
||||
|
|
@ -147,34 +168,40 @@ public class ReviewController extends BaseApiController {
|
|||
.filter(task -> canViewReview(task, userId, namespaceRoles))
|
||||
.map(this::toResponse)
|
||||
.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")
|
||||
public ApiResponse<PageResponse<ReviewTaskResponse>> listPendingReviews(
|
||||
@RequestParam Long namespaceId,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size,
|
||||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
|
||||
public ApiResponse<PageResponse<ReviewTaskResponse>> listPendingReviews(@RequestParam Long namespaceId,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size,
|
||||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
|
||||
Namespace namespace = namespaceRepository.findById(namespaceId)
|
||||
.orElseThrow(() -> new DomainNotFoundException("namespace.not_found", namespaceId));
|
||||
ReviewTask probe = new ReviewTask(0L, namespaceId, "probe");
|
||||
if (!reviewService.canReviewNamespace(probe, userId, namespace.getType(),
|
||||
userNsRoles != null ? userNsRoles : Map.of(), rbacService.getUserRoleCodes(userId))) {
|
||||
ReviewTask probe = new ReviewTask(0L, namespaceId, userId);
|
||||
if (!reviewService.canReviewNamespace(
|
||||
probe,
|
||||
userId,
|
||||
namespace.getType(),
|
||||
userNsRoles != null ? userNsRoles : Map.of(),
|
||||
rbacService.getUserRoleCodes(userId))) {
|
||||
throw new DomainForbiddenException("review.no_permission");
|
||||
}
|
||||
|
||||
Page<ReviewTask> tasks = reviewTaskRepository.findByNamespaceIdAndStatus(
|
||||
namespaceId, ReviewTaskStatus.PENDING, PageRequest.of(page, size));
|
||||
return ok("response.success.read", PageResponse.from(tasks.map(this::toResponse)));
|
||||
}
|
||||
|
||||
@GetMapping("/my-submissions")
|
||||
public ApiResponse<PageResponse<ReviewTaskResponse>> listMySubmissions(
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size,
|
||||
@RequestAttribute("userId") String userId) {
|
||||
public ApiResponse<PageResponse<ReviewTaskResponse>> listMySubmissions(@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size,
|
||||
@RequestAttribute("userId") String userId) {
|
||||
Page<ReviewTask> tasks = reviewTaskRepository.findBySubmittedByAndStatus(
|
||||
userId, ReviewTaskStatus.PENDING, PageRequest.of(page, size));
|
||||
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));
|
||||
Namespace namespace = namespaceRepository.findById(task.getNamespaceId())
|
||||
.orElseThrow(() -> new DomainNotFoundException("namespace.not_found", task.getNamespaceId()));
|
||||
if (!reviewService.canViewReview(task, userId, namespace.getType(),
|
||||
userNsRoles != null ? userNsRoles : Map.of(), rbacService.getUserRoleCodes(userId))) {
|
||||
if (!reviewService.canViewReview(
|
||||
task,
|
||||
userId,
|
||||
namespace.getType(),
|
||||
userNsRoles != null ? userNsRoles : Map.of(),
|
||||
rbacService.getUserRoleCodes(userId))) {
|
||||
throw new DomainForbiddenException("review.no_permission");
|
||||
}
|
||||
return ok("response.success.read", toResponse(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()));
|
||||
Skill skill = skillRepository.findById(sv.getSkillId())
|
||||
.orElseThrow(() -> new DomainNotFoundException("skill.not_found", sv.getSkillId()));
|
||||
Namespace ns = namespaceRepository.findById(skill.getNamespaceId())
|
||||
Skill skill = skillRepository.findById(skillVersion.getSkillId())
|
||||
.orElseThrow(() -> new DomainNotFoundException("skill.not_found", skillVersion.getSkillId()));
|
||||
Namespace namespace = namespaceRepository.findById(skill.getNamespaceId())
|
||||
.orElseThrow(() -> new DomainNotFoundException("namespace.not_found", skill.getNamespaceId()));
|
||||
|
||||
String submittedByName = userAccountRepository.findById(task.getSubmittedBy())
|
||||
.map(UserAccount::getDisplayName).orElse(null);
|
||||
|
||||
.map(UserAccount::getDisplayName)
|
||||
.orElse(null);
|
||||
String reviewedByName = task.getReviewedBy() != null
|
||||
? userAccountRepository.findById(task.getReviewedBy())
|
||||
.map(UserAccount::getDisplayName).orElse(null)
|
||||
? userAccountRepository.findById(task.getReviewedBy()).map(UserAccount::getDisplayName).orElse(null)
|
||||
: null;
|
||||
|
||||
return new ReviewTaskResponse(
|
||||
task.getId(),
|
||||
task.getSkillVersionId(),
|
||||
ns.getSlug(),
|
||||
namespace.getSlug(),
|
||||
skill.getSlug(),
|
||||
sv.getVersion(),
|
||||
skillVersion.getVersion(),
|
||||
task.getStatus().name(),
|
||||
task.getSubmittedBy(),
|
||||
submittedByName,
|
||||
|
|
@ -231,7 +261,13 @@ public class ReviewController extends BaseApiController {
|
|||
private boolean canViewReview(ReviewTask task, String userId, Map<Long, NamespaceRole> namespaceRoles) {
|
||||
Namespace namespace = namespaceRepository.findById(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,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ package com.iflytek.skillhub.controller.portal;
|
|||
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
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.service.SkillPublishService;
|
||||
import com.iflytek.skillhub.domain.skill.validation.PackageEntry;
|
||||
|
|
@ -23,16 +24,16 @@ import java.util.List;
|
|||
public class SkillPublishController extends BaseApiController {
|
||||
|
||||
private final SkillPublishService skillPublishService;
|
||||
private final ZipPackageExtractor zipPackageExtractor;
|
||||
private final SkillPackageArchiveExtractor skillPackageArchiveExtractor;
|
||||
private final SkillHubMetrics skillHubMetrics;
|
||||
|
||||
public SkillPublishController(SkillPublishService skillPublishService,
|
||||
ZipPackageExtractor zipPackageExtractor,
|
||||
SkillPackageArchiveExtractor skillPackageArchiveExtractor,
|
||||
ApiResponseFactory responseFactory,
|
||||
SkillHubMetrics skillHubMetrics) {
|
||||
super(responseFactory);
|
||||
this.skillPublishService = skillPublishService;
|
||||
this.zipPackageExtractor = zipPackageExtractor;
|
||||
this.skillPackageArchiveExtractor = skillPackageArchiveExtractor;
|
||||
this.skillHubMetrics = skillHubMetrics;
|
||||
}
|
||||
|
||||
|
|
@ -46,7 +47,12 @@ public class SkillPublishController extends BaseApiController {
|
|||
|
||||
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(
|
||||
namespace,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
@ -4,11 +4,11 @@ import java.time.LocalDateTime;
|
|||
import java.util.List;
|
||||
|
||||
public record AdminUserSummaryResponse(
|
||||
String userId,
|
||||
String id,
|
||||
String username,
|
||||
String email,
|
||||
List<String> platformRoles,
|
||||
String status,
|
||||
List<String> platformRoles,
|
||||
LocalDateTime createdAt
|
||||
) {
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,12 @@ package com.iflytek.skillhub.dto;
|
|||
import java.time.Instant;
|
||||
|
||||
public record AuditLogItemResponse(
|
||||
String id,
|
||||
String userId,
|
||||
Long id,
|
||||
String action,
|
||||
String resourceType,
|
||||
String resourceId,
|
||||
Instant timestamp,
|
||||
String ipAddress
|
||||
String userId,
|
||||
String username,
|
||||
String details,
|
||||
String ipAddress,
|
||||
Instant timestamp
|
||||
) {
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -110,8 +110,8 @@ public class AdminUserManagementService {
|
|||
user.getId(),
|
||||
user.getDisplayName(),
|
||||
user.getEmail(),
|
||||
List.copyOf(roles),
|
||||
user.getStatus().name(),
|
||||
List.copyOf(roles),
|
||||
user.getCreatedAt()
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.precheck.failed=Pre-publish validation failed: {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.status.notActive=Skill is not active
|
||||
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.invalid=Device code expired or invalid
|
||||
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.auth.local.username.exists=Username already exists
|
||||
error.auth.local.email.exists=Email already exists
|
||||
error.auth.local.invalidCredentials=Invalid username or password
|
||||
error.auth.local.accountDisabled=Account has been disabled
|
||||
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}
|
||||
error.admin.user.notFound=User not found: {0}
|
||||
error.admin.user.role.invalid=Invalid role: {0}
|
||||
error.admin.user.role.superAdmin.assignDenied=Only SUPER_ADMIN can assign SUPER_ADMIN role
|
||||
error.admin.user.status.invalid=Invalid user status: {0}
|
||||
error.admin.user.status.unsupported=Only ACTIVE or DISABLED status can be managed here
|
||||
|
|
|
|||
|
|
@ -51,12 +51,6 @@ error.skill.publish.package.invalid=技能包校验失败:{0}
|
|||
error.skill.publish.skillMd.notFound=未找到 SKILL.md
|
||||
error.skill.publish.precheck.failed=预发布校验失败:{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.status.notActive=技能未处于 ACTIVE 状态
|
||||
error.skill.version.exists=版本已存在:{0}
|
||||
|
|
@ -77,33 +71,8 @@ error.deviceAuth.userCode.invalid=无效或已过期的用户验证码
|
|||
error.deviceAuth.deviceCode.expired=设备验证码已过期
|
||||
error.deviceAuth.deviceCode.invalid=设备验证码无效或已过期
|
||||
error.deviceAuth.deviceCode.used=设备验证码已被使用
|
||||
error.auth.local.username.invalid=用户名长度必须为 3 到 64 个字符,且只能包含字母、数字或下划线
|
||||
error.auth.local.username.exists=用户名已存在
|
||||
error.auth.local.email.exists=邮箱已存在
|
||||
error.auth.local.invalidCredentials=用户名或密码错误
|
||||
error.auth.local.accountDisabled=账号已被禁用
|
||||
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}
|
||||
error.admin.user.notFound=鐢ㄦ埛涓嶅瓨鍦細{0}
|
||||
error.admin.user.role.invalid=鏃犳晥鐨勮鑹诧細{0}
|
||||
error.admin.user.role.superAdmin.assignDenied=鍙湁 SUPER_ADMIN 鍙互鍒嗛厤 SUPER_ADMIN 瑙掕壊
|
||||
error.admin.user.status.invalid=鏃犳晥鐨勭敤鎴风姸鎬侊細{0}
|
||||
error.admin.user.status.unsupported=杩欓噷鍙厑璁告寜 ACTIVE 鎴?DISABLED 绠$悊鐢ㄦ埛鐘舵€?
|
||||
|
|
|
|||
|
|
@ -3,40 +3,28 @@ package com.iflytek.skillhub.compat;
|
|||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
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.dto.SkillSummaryResponse;
|
||||
import com.iflytek.skillhub.service.SkillSearchAppService;
|
||||
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.mock.web.MockMultipartFile;
|
||||
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.io.ByteArrayOutputStream;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
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.csrf;
|
||||
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.*;
|
||||
|
||||
@SpringBootTest
|
||||
|
|
@ -58,44 +46,43 @@ class ClawHubCompatControllerTest {
|
|||
|
||||
@MockBean
|
||||
private SkillQueryService skillQueryService;
|
||||
@MockBean
|
||||
private SkillPublishService skillPublishService;
|
||||
@MockBean
|
||||
private AuditLogService auditLogService;
|
||||
|
||||
@Test
|
||||
void search_returns_200() throws Exception {
|
||||
given(skillSearchAppService.search("test", null, "relevance", 0, 20, null, Map.of()))
|
||||
.willReturn(new SkillSearchAppService.SearchResponse(List.of(), 0, 0, 20));
|
||||
void search_returns_mapped_results() throws Exception {
|
||||
when(skillSearchAppService.search("test", null, "relevance", 0, 20, null, null))
|
||||
.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")
|
||||
.param("q", "test"))
|
||||
.andExpect(status().isOk())
|
||||
.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
|
||||
void resolve_returns_correct_downloadUrl() throws Exception {
|
||||
given(skillQueryService.resolveVersion(
|
||||
eq("global"),
|
||||
eq("my-skill"),
|
||||
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"
|
||||
));
|
||||
|
||||
when(skillQueryService.resolveVersion("global", "my-skill", null, "latest", null, null, java.util.Map.of()))
|
||||
.thenReturn(new SkillQueryService.ResolvedVersionDTO(
|
||||
1L, "global", "my-skill", "latest", 2L, "sha", true, "/api/v1/skills/global/my-skill/download"));
|
||||
mockMvc.perform(get("/api/compat/v1/resolve/my-skill"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.canonicalSlug").value("my-skill"))
|
||||
|
|
@ -105,25 +92,9 @@ class ClawHubCompatControllerTest {
|
|||
|
||||
@Test
|
||||
void resolve_with_namespace_returns_correct_downloadUrl() throws Exception {
|
||||
given(skillQueryService.resolveVersion(
|
||||
eq("team-ai"),
|
||||
eq("my-skill"),
|
||||
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"
|
||||
));
|
||||
|
||||
when(skillQueryService.resolveVersion("team-ai", "my-skill", null, "latest", null, null, java.util.Map.of()))
|
||||
.thenReturn(new SkillQueryService.ResolvedVersionDTO(
|
||||
1L, "team-ai", "my-skill", "latest", 2L, "sha", true, "/api/v1/skills/team-ai/my-skill/download"));
|
||||
mockMvc.perform(get("/api/compat/v1/resolve/team-ai--my-skill"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.canonicalSlug").value("team-ai--my-skill"))
|
||||
|
|
@ -133,25 +104,9 @@ class ClawHubCompatControllerTest {
|
|||
|
||||
@Test
|
||||
void resolve_with_version_returns_specified_version() throws Exception {
|
||||
given(skillQueryService.resolveVersion(
|
||||
eq("global"),
|
||||
eq("my-skill"),
|
||||
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"
|
||||
));
|
||||
|
||||
when(skillQueryService.resolveVersion("global", "my-skill", "1.0.0", null, null, null, java.util.Map.of()))
|
||||
.thenReturn(new SkillQueryService.ResolvedVersionDTO(
|
||||
1L, "global", "my-skill", "1.0.0", 2L, "sha", true, "/api/v1/skills/global/my-skill/download"));
|
||||
mockMvc.perform(get("/api/compat/v1/resolve/my-skill")
|
||||
.param("version", "1.0.0"))
|
||||
.andExpect(status().isOk())
|
||||
|
|
@ -184,62 +139,4 @@ class ClawHubCompatControllerTest {
|
|||
.andExpect(jsonPath("$.displayName").value("tester"))
|
||||
.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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,6 @@ package com.iflytek.skillhub.controller;
|
|||
import com.iflytek.skillhub.auth.device.DeviceAuthService;
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
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.springframework.beans.factory.annotation.Autowired;
|
||||
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.ZipOutputStream;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
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.csrf;
|
||||
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.jsonPath;
|
||||
|
|
@ -49,12 +40,6 @@ class CliControllerTest {
|
|||
|
||||
@MockBean
|
||||
private DeviceAuthService deviceAuthService;
|
||||
@MockBean
|
||||
private SkillPublishService skillPublishService;
|
||||
@MockBean
|
||||
private SkillHubMetrics skillHubMetrics;
|
||||
@MockBean
|
||||
private AuditLogService auditLogService;
|
||||
|
||||
@Test
|
||||
void whoamiShouldReturnUnauthorizedForAnonymousRequest() throws Exception {
|
||||
|
|
@ -148,50 +133,22 @@ class CliControllerTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
void publishShouldPassPlatformRolesToDomainService() throws Exception {
|
||||
SkillVersion version = new SkillVersion(12L, "1.0.0", "user-7");
|
||||
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"))
|
||||
);
|
||||
|
||||
void checkShouldReturnInvalidForPathTraversalEntry() throws Exception {
|
||||
byte[] zipBytes = createZipWithUnsafePath();
|
||||
MockMultipartFile file = new MockMultipartFile(
|
||||
"file",
|
||||
"skill.zip",
|
||||
"application/zip",
|
||||
createValidSkillZip()
|
||||
zipBytes
|
||||
);
|
||||
|
||||
mockMvc.perform(multipart("/api/v1/cli/publish")
|
||||
.file(file)
|
||||
.param("namespace", "global")
|
||||
.param("visibility", "PUBLIC")
|
||||
.with(authentication(auth))
|
||||
.with(csrf()))
|
||||
mockMvc.perform(multipart("/api/v1/cli/check").file(file))
|
||||
.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 {
|
||||
|
|
@ -253,4 +210,15 @@ class CliControllerTest {
|
|||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,4 +70,20 @@ class DeviceAuthControllerTest {
|
|||
.andExpect(jsonPath("$.data.accessToken").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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
package com.iflytek.skillhub.controller;
|
||||
|
||||
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.social.SkillRatingService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
|
@ -110,31 +109,8 @@ class SkillRatingControllerTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
void get_user_rating_missing_skill_returns_404_envelope() throws Exception {
|
||||
PlatformPrincipal principal = new PlatformPrincipal(
|
||||
"user-42",
|
||||
"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());
|
||||
void get_user_rating_unauthenticated_returns_401() throws Exception {
|
||||
mockMvc.perform(get("/api/v1/skills/10/rating"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
package com.iflytek.skillhub.controller;
|
||||
|
||||
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.social.SkillStarService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
|
@ -129,31 +128,8 @@ class SkillStarControllerTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
void check_starred_missing_skill_returns_404_envelope() throws Exception {
|
||||
PlatformPrincipal principal = new PlatformPrincipal(
|
||||
"user-42",
|
||||
"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());
|
||||
void check_starred_unauthenticated_returns_401() throws Exception {
|
||||
mockMvc.perform(get("/api/v1/skills/10/star"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -3,27 +3,26 @@ package com.iflytek.skillhub.controller.admin;
|
|||
import com.iflytek.skillhub.TestRedisConfig;
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
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.dto.AuditLogItemResponse;
|
||||
import com.iflytek.skillhub.dto.PageResponse;
|
||||
import com.iflytek.skillhub.service.AdminAuditLogAppService;
|
||||
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.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
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.time.Instant;
|
||||
import java.util.List;
|
||||
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.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
|
|
@ -45,7 +44,7 @@ class AuditLogControllerTest {
|
|||
private DeviceAuthService deviceAuthService;
|
||||
|
||||
@MockBean
|
||||
private AuditLogQueryService auditLogQueryService;
|
||||
private AdminAuditLogAppService adminAuditLogAppService;
|
||||
|
||||
@Test
|
||||
void listAuditLogs_unauthenticated_returns401() throws Exception {
|
||||
|
|
@ -55,15 +54,6 @@ class AuditLogControllerTest {
|
|||
|
||||
@Test
|
||||
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(
|
||||
"user-50", "auditor", "auditor@example.com", "", "github", Set.of("AUDITOR")
|
||||
);
|
||||
|
|
@ -71,17 +61,31 @@ class AuditLogControllerTest {
|
|||
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)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.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
|
||||
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(
|
||||
"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"))
|
||||
);
|
||||
|
||||
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)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.items").isArray());
|
||||
|
|
@ -96,8 +103,6 @@ class AuditLogControllerTest {
|
|||
|
||||
@Test
|
||||
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(
|
||||
"user-50", "auditor", "auditor@example.com", "", "github", Set.of("AUDITOR")
|
||||
);
|
||||
|
|
@ -105,6 +110,9 @@ class AuditLogControllerTest {
|
|||
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")
|
||||
.param("userId", "user-1")
|
||||
.param("action", "CREATE_SKILL")
|
||||
|
|
|
|||
|
|
@ -4,9 +4,10 @@ import com.iflytek.skillhub.TestRedisConfig;
|
|||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.auth.device.DeviceAuthService;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
import com.iflytek.skillhub.dto.AdminUserMutationResponse;
|
||||
import com.iflytek.skillhub.dto.AdminUserSummaryResponse;
|
||||
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.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
|
|
@ -22,7 +23,6 @@ import java.util.List;
|
|||
import java.util.Set;
|
||||
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.csrf;
|
||||
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.status;
|
||||
import static org.springframework.http.MediaType.APPLICATION_JSON;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
|
|
@ -47,7 +48,7 @@ class UserManagementControllerTest {
|
|||
private DeviceAuthService deviceAuthService;
|
||||
|
||||
@MockBean
|
||||
private AdminUserManagementService adminUserManagementService;
|
||||
private AdminUserAppService adminUserAppService;
|
||||
|
||||
@Test
|
||||
void listUsers_unauthenticated_returns401() throws Exception {
|
||||
|
|
@ -57,17 +58,6 @@ class UserManagementControllerTest {
|
|||
|
||||
@Test
|
||||
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(
|
||||
"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"))
|
||||
);
|
||||
|
||||
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)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.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
|
||||
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(
|
||||
"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"))
|
||||
);
|
||||
|
||||
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)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.items").isArray());
|
||||
|
|
@ -106,9 +107,6 @@ class UserManagementControllerTest {
|
|||
|
||||
@Test
|
||||
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(
|
||||
"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"))
|
||||
);
|
||||
|
||||
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")
|
||||
.with(authentication(auth))
|
||||
|
|
@ -126,14 +127,12 @@ class UserManagementControllerTest {
|
|||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.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
|
||||
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(
|
||||
"user-42", "admin", "admin@example.com", "", "github", Set.of("USER_ADMIN")
|
||||
);
|
||||
|
|
@ -143,6 +142,9 @@ class UserManagementControllerTest {
|
|||
|
||||
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")
|
||||
.with(authentication(auth))
|
||||
.with(csrf())
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import com.iflytek.skillhub.auth.oauth.OAuth2LoginSuccessHandler;
|
|||
import com.iflytek.skillhub.auth.oauth.SkillHubOAuth2AuthorizationRequestResolver;
|
||||
import com.iflytek.skillhub.auth.mock.MockAuthFilter;
|
||||
import com.iflytek.skillhub.auth.token.ApiTokenAuthenticationFilter;
|
||||
import com.iflytek.skillhub.auth.token.ApiTokenScopeFilter;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
|
@ -35,6 +36,7 @@ public class SecurityConfig {
|
|||
private final OAuth2LoginSuccessHandler successHandler;
|
||||
private final OAuth2LoginFailureHandler failureHandler;
|
||||
private final ApiTokenAuthenticationFilter apiTokenAuthenticationFilter;
|
||||
private final ApiTokenScopeFilter apiTokenScopeFilter;
|
||||
private final AuthenticationEntryPoint apiAuthenticationEntryPoint;
|
||||
private final AccessDeniedHandler apiAccessDeniedHandler;
|
||||
private final ObjectProvider<MockAuthFilter> mockAuthFilterProvider;
|
||||
|
|
@ -44,6 +46,7 @@ public class SecurityConfig {
|
|||
OAuth2LoginSuccessHandler successHandler,
|
||||
OAuth2LoginFailureHandler failureHandler,
|
||||
ApiTokenAuthenticationFilter apiTokenAuthenticationFilter,
|
||||
ApiTokenScopeFilter apiTokenScopeFilter,
|
||||
AuthenticationEntryPoint apiAuthenticationEntryPoint,
|
||||
AccessDeniedHandler apiAccessDeniedHandler,
|
||||
ObjectProvider<MockAuthFilter> mockAuthFilterProvider) {
|
||||
|
|
@ -52,6 +55,7 @@ public class SecurityConfig {
|
|||
this.successHandler = successHandler;
|
||||
this.failureHandler = failureHandler;
|
||||
this.apiTokenAuthenticationFilter = apiTokenAuthenticationFilter;
|
||||
this.apiTokenScopeFilter = apiTokenScopeFilter;
|
||||
this.apiAuthenticationEntryPoint = apiAuthenticationEntryPoint;
|
||||
this.apiAccessDeniedHandler = apiAccessDeniedHandler;
|
||||
this.mockAuthFilterProvider = mockAuthFilterProvider;
|
||||
|
|
@ -88,7 +92,9 @@ public class SecurityConfig {
|
|||
"/api/compat/v1/resolve/**",
|
||||
"/api/compat/v1/download/**"
|
||||
).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/*/*/versions",
|
||||
|
|
@ -134,7 +140,8 @@ public class SecurityConfig {
|
|||
.invalidateHttpSession(true)
|
||||
.deleteCookies("SESSION")
|
||||
)
|
||||
.addFilterBefore(apiTokenAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
.addFilterBefore(apiTokenAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
|
||||
.addFilterAfter(apiTokenScopeFilter, ApiTokenAuthenticationFilter.class);
|
||||
|
||||
MockAuthFilter mockAuthFilter = mockAuthFilterProvider.getIfAvailable();
|
||||
if (mockAuthFilter != null) {
|
||||
|
|
|
|||
|
|
@ -1,15 +1,11 @@
|
|||
package com.iflytek.skillhub.auth.device;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.iflytek.skillhub.auth.token.ApiTokenService;
|
||||
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.data.redis.core.RedisOperations;
|
||||
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.util.StringUtils;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
|
|
@ -19,32 +15,27 @@ import java.util.concurrent.TimeUnit;
|
|||
public class DeviceAuthService {
|
||||
|
||||
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 int EXPIRES_IN_SECONDS = 900;
|
||||
private static final int POLL_INTERVAL_SECONDS = 5;
|
||||
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 String verificationUri;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ApiTokenService apiTokenService;
|
||||
private final String verificationUri;
|
||||
private final SecureRandom random = new SecureRandom();
|
||||
|
||||
@Autowired
|
||||
public DeviceAuthService(RedisTemplate<String, Object> redisTemplate,
|
||||
ApiTokenService apiTokenService,
|
||||
@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.verificationUri = verificationUri;
|
||||
this.objectMapper = objectMapper;
|
||||
this.apiTokenService = apiTokenService;
|
||||
this.verificationUri = verificationUri;
|
||||
}
|
||||
|
||||
public DeviceCodeResponse generateDeviceCode() {
|
||||
|
|
@ -54,9 +45,9 @@ public class DeviceAuthService {
|
|||
DeviceCodeData data = new DeviceCodeData(deviceCode, userCode, DeviceCodeStatus.PENDING, null);
|
||||
|
||||
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(
|
||||
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);
|
||||
}
|
||||
|
|
@ -67,60 +58,76 @@ public class DeviceAuthService {
|
|||
throw new DomainBadRequestException("error.deviceAuth.userCode.invalid");
|
||||
}
|
||||
|
||||
DeviceCodeData data = readDeviceCodeData(deviceCode);
|
||||
DeviceCodeData data = (DeviceCodeData) redisTemplate.opsForValue().get(DEVICE_CODE_PREFIX + deviceCode);
|
||||
if (data == null) {
|
||||
throw new DomainBadRequestException("error.deviceAuth.deviceCode.expired");
|
||||
}
|
||||
|
||||
data.setStatus(DeviceCodeStatus.AUTHORIZED);
|
||||
data.setUserId(userId);
|
||||
redisTemplate.opsForValue().set(
|
||||
DEVICE_CODE_PREFIX + deviceCode, data, EXPIRES_IN_SECONDS / 60, TimeUnit.MINUTES);
|
||||
switch (data.getStatus()) {
|
||||
case PENDING -> {
|
||||
data.setStatus(DeviceCodeStatus.AUTHORIZED);
|
||||
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) {
|
||||
String key = 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);
|
||||
DeviceCodeData data = (DeviceCodeData) redisTemplate.opsForValue().get(DEVICE_CODE_PREFIX + deviceCode);
|
||||
|
||||
if (data == null) {
|
||||
operations.unwatch();
|
||||
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();
|
||||
if (data == null) {
|
||||
throw new DomainBadRequestException("error.deviceAuth.deviceCode.invalid");
|
||||
}
|
||||
|
||||
String token = apiTokenService.createToken(
|
||||
consumed.getUserId(), "device-auth", "[]").rawToken();
|
||||
return DeviceTokenResponse.success(token);
|
||||
return switch (data.getStatus()) {
|
||||
case PENDING -> DeviceTokenResponse.pending();
|
||||
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() {
|
||||
|
|
@ -137,19 +144,4 @@ public class DeviceAuthService {
|
|||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
package com.iflytek.skillhub.auth.repository;
|
||||
|
||||
import com.iflytek.skillhub.auth.entity.UserRoleBinding;
|
||||
import java.util.Collection;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ import org.springframework.stereotype.Component;
|
|||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
|
|
@ -28,13 +31,16 @@ public class ApiTokenAuthenticationFilter extends OncePerRequestFilter {
|
|||
private final ApiTokenService apiTokenService;
|
||||
private final UserAccountRepository userRepo;
|
||||
private final UserRoleBindingRepository roleBindingRepo;
|
||||
private final ApiTokenScopeService apiTokenScopeService;
|
||||
|
||||
public ApiTokenAuthenticationFilter(ApiTokenService apiTokenService,
|
||||
UserAccountRepository userRepo,
|
||||
UserRoleBindingRepository roleBindingRepo) {
|
||||
UserAccountRepository userRepo,
|
||||
UserRoleBindingRepository roleBindingRepo,
|
||||
ApiTokenScopeService apiTokenScopeService) {
|
||||
this.apiTokenService = apiTokenService;
|
||||
this.userRepo = userRepo;
|
||||
this.roleBindingRepo = roleBindingRepo;
|
||||
this.apiTokenScopeService = apiTokenScopeService;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
@ -44,20 +50,28 @@ public class ApiTokenAuthenticationFilter extends OncePerRequestFilter {
|
|||
if (authHeader != null && authHeader.startsWith(BEARER_PREFIX)) {
|
||||
String rawToken = authHeader.substring(BEARER_PREFIX.length());
|
||||
apiTokenService.validateToken(rawToken).ifPresent(token -> {
|
||||
apiTokenService.touchLastUsed(token);
|
||||
userRepo.findById(token.getUserId()).ifPresent(user -> {
|
||||
if (!user.isActive()) {
|
||||
return;
|
||||
}
|
||||
Set<String> roles = roleBindingRepo.findByUserId(user.getId()).stream()
|
||||
.map(rb -> rb.getRole().getCode())
|
||||
.collect(Collectors.toSet());
|
||||
Set<String> scopes = apiTokenScopeService.parseScopes(token.getScopeJson());
|
||||
PlatformPrincipal principal = new PlatformPrincipal(
|
||||
user.getId(), user.getDisplayName(), user.getEmail(),
|
||||
user.getAvatarUrl(), "api_token", roles
|
||||
);
|
||||
var authorities = roles.stream()
|
||||
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
|
||||
authorities.addAll(roles.stream()
|
||||
.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);
|
||||
SecurityContextHolder.getContext().setAuthentication(auth);
|
||||
apiTokenService.touchLastUsed(token);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,21 +1,17 @@
|
|||
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.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
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.ValueOperations;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
|
@ -31,8 +27,6 @@ class DeviceAuthServiceTest {
|
|||
|
||||
@Mock
|
||||
private ValueOperations<String, Object> valueOperations;
|
||||
@Mock
|
||||
private RedisOperations<String, Object> redisOperations;
|
||||
|
||||
@Mock
|
||||
private ApiTokenService apiTokenService;
|
||||
|
|
@ -41,11 +35,8 @@ class DeviceAuthServiceTest {
|
|||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
lenient().when(redisTemplate.opsForValue()).thenReturn(valueOperations);
|
||||
lenient().when(redisOperations.opsForValue()).thenReturn(valueOperations);
|
||||
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());
|
||||
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
|
||||
service = new DeviceAuthService(redisTemplate, apiTokenService, "https://skillhub.example.com/device");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -91,6 +82,41 @@ class DeviceAuthServiceTest {
|
|||
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
|
||||
void pollToken_returns_error_when_expired() {
|
||||
// Given
|
||||
|
|
@ -119,62 +145,15 @@ class DeviceAuthServiceTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
void authorizeDeviceCode_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", "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() {
|
||||
void authorizeDeviceCode_rejects_different_user_after_authorization() {
|
||||
// Given
|
||||
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(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"), eq(data), eq(1L), eq(TimeUnit.MINUTES));
|
||||
verify(redisOperations).exec();
|
||||
// When / Then
|
||||
assertThatThrownBy(() -> service.authorizeDeviceCode("ABCD-1234", "99"))
|
||||
.isInstanceOf(DomainBadRequestException.class)
|
||||
.hasMessageContaining("error.deviceAuth.deviceCode.alreadyAuthorized");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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.DomainNotFoundException;
|
||||
import com.iflytek.skillhub.domain.skill.*;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ConcurrentModificationException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
|
|
@ -30,7 +29,6 @@ public class PromotionService {
|
|||
private final NamespaceRepository namespaceRepository;
|
||||
private final ReviewPermissionChecker permissionChecker;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
private final EntityManager entityManager;
|
||||
|
||||
public PromotionService(PromotionRequestRepository promotionRequestRepository,
|
||||
SkillRepository skillRepository,
|
||||
|
|
@ -38,8 +36,7 @@ public class PromotionService {
|
|||
SkillFileRepository skillFileRepository,
|
||||
NamespaceRepository namespaceRepository,
|
||||
ReviewPermissionChecker permissionChecker,
|
||||
ApplicationEventPublisher eventPublisher,
|
||||
EntityManager entityManager) {
|
||||
ApplicationEventPublisher eventPublisher) {
|
||||
this.promotionRequestRepository = promotionRequestRepository;
|
||||
this.skillRepository = skillRepository;
|
||||
this.skillVersionRepository = skillVersionRepository;
|
||||
|
|
@ -47,13 +44,12 @@ public class PromotionService {
|
|||
this.namespaceRepository = namespaceRepository;
|
||||
this.permissionChecker = permissionChecker;
|
||||
this.eventPublisher = eventPublisher;
|
||||
this.entityManager = entityManager;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PromotionRequest submitPromotion(Long sourceSkillId, Long sourceVersionId,
|
||||
Long targetNamespaceId, String userId,
|
||||
java.util.Map<Long, NamespaceRole> userNamespaceRoles,
|
||||
Map<Long, NamespaceRole> userNamespaceRoles,
|
||||
Set<String> platformRoles) {
|
||||
Skill sourceSkill = skillRepository.findById(sourceSkillId)
|
||||
.orElseThrow(() -> new DomainNotFoundException("skill.not_found", sourceSkillId));
|
||||
|
|
@ -89,6 +85,44 @@ public class PromotionService {
|
|||
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
|
||||
public PromotionRequest approvePromotion(Long promotionId, String reviewerId,
|
||||
String comment, Set<String> platformRoles) {
|
||||
|
|
@ -108,11 +142,6 @@ public class PromotionService {
|
|||
if (updated == 0) {
|
||||
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())
|
||||
.orElseThrow(() -> new DomainNotFoundException("skill.not_found", request.getSourceSkillId()));
|
||||
|
|
@ -155,12 +184,9 @@ public class PromotionService {
|
|||
.toList();
|
||||
skillFileRepository.saveAll(copiedFiles);
|
||||
|
||||
int targetUpdated = promotionRequestRepository.updateStatusWithVersion(
|
||||
promotionId, ReviewTaskStatus.APPROVED, reviewerId, comment, newSkill.getId(), request.getVersion() + 1);
|
||||
if (targetUpdated == 0) {
|
||||
throw new ConcurrentModificationException("Promotion request target skill was modified concurrently");
|
||||
}
|
||||
// Update promotion request with target skill id
|
||||
request.setTargetSkillId(newSkill.getId());
|
||||
promotionRequestRepository.save(request);
|
||||
|
||||
eventPublisher.publishEvent(new SkillPublishedEvent(
|
||||
newSkill.getId(), newVersion.getId(), reviewerId));
|
||||
|
|
@ -187,12 +213,8 @@ public class PromotionService {
|
|||
if (updated == 0) {
|
||||
throw new ConcurrentModificationException("Promotion request was modified concurrently");
|
||||
}
|
||||
entityManager.detach(request);
|
||||
request.setStatus(ReviewTaskStatus.REJECTED);
|
||||
request.setReviewedBy(reviewerId);
|
||||
request.setReviewComment(comment);
|
||||
request.setReviewedAt(Instant.now());
|
||||
return request;
|
||||
|
||||
return promotionRequestRepository.findById(promotionId).orElse(request);
|
||||
}
|
||||
|
||||
public boolean canViewPromotion(PromotionRequest request, String userId, Set<String> platformRoles) {
|
||||
|
|
|
|||
|
|
@ -11,27 +11,22 @@ import java.util.Set;
|
|||
@Component
|
||||
public class ReviewPermissionChecker {
|
||||
|
||||
/**
|
||||
* Check if a user can review a ReviewTask.
|
||||
*
|
||||
* @param task the review task
|
||||
* @param userId the reviewer's user ID
|
||||
* @param namespaceType the type of the namespace
|
||||
* @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 canSubmitReview(Long namespaceId,
|
||||
Map<Long, NamespaceRole> userNamespaceRoles) {
|
||||
NamespaceRole role = userNamespaceRoles.get(namespaceId);
|
||||
return role == NamespaceRole.OWNER
|
||||
|| role == NamespaceRole.ADMIN
|
||||
|| role == NamespaceRole.MEMBER;
|
||||
}
|
||||
|
||||
public boolean canReview(ReviewTask task,
|
||||
String userId,
|
||||
NamespaceType namespaceType,
|
||||
Map<Long, NamespaceRole> userNamespaceRoles,
|
||||
Set<String> platformRoles) {
|
||||
// Admins can review their own submissions
|
||||
if (task.getSubmittedBy().equals(userId)) {
|
||||
return platformRoles.contains("SKILL_ADMIN")
|
||||
|| platformRoles.contains("SUPER_ADMIN");
|
||||
return false;
|
||||
}
|
||||
|
||||
return canReviewNamespace(task.getNamespaceId(), namespaceType, userNamespaceRoles, platformRoles);
|
||||
}
|
||||
|
||||
|
|
@ -42,9 +37,7 @@ public class ReviewPermissionChecker {
|
|||
if (skill.getOwnerId().equals(userId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (platformRoles.contains("SKILL_ADMIN")
|
||||
|| platformRoles.contains("SUPER_ADMIN")) {
|
||||
if (hasPlatformReviewRole(platformRoles)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -67,23 +60,45 @@ public class ReviewPermissionChecker {
|
|||
NamespaceType namespaceType,
|
||||
Map<Long, NamespaceRole> userNamespaceRoles,
|
||||
Set<String> platformRoles) {
|
||||
if (platformRoles.contains("SKILL_ADMIN")
|
||||
|| platformRoles.contains("SUPER_ADMIN")) {
|
||||
if (hasPlatformReviewRole(platformRoles)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (namespaceType == NamespaceType.GLOBAL) {
|
||||
return false;
|
||||
}
|
||||
|
||||
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(
|
||||
PromotionRequest request,
|
||||
String userId,
|
||||
|
|
@ -91,15 +106,7 @@ public class ReviewPermissionChecker {
|
|||
if (request.getSubmittedBy().equals(userId)) {
|
||||
return false;
|
||||
}
|
||||
return platformRoles.contains("SKILL_ADMIN")
|
||||
|| platformRoles.contains("SUPER_ADMIN");
|
||||
}
|
||||
|
||||
public boolean canSubmitPromotion(Skill skill,
|
||||
String userId,
|
||||
Map<Long, NamespaceRole> userNamespaceRoles,
|
||||
Set<String> platformRoles) {
|
||||
return canSubmitForReview(skill, userId, userNamespaceRoles, platformRoles);
|
||||
return hasPlatformReviewRole(platformRoles);
|
||||
}
|
||||
|
||||
public boolean canViewPromotion(PromotionRequest request,
|
||||
|
|
@ -110,4 +117,19 @@ public class ReviewPermissionChecker {
|
|||
}
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
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.NamespaceRepository;
|
||||
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.SkillVersionRepository;
|
||||
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.dao.DataIntegrityViolationException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ConcurrentModificationException;
|
||||
import java.util.Map;
|
||||
|
|
@ -33,7 +33,7 @@ public class ReviewService {
|
|||
private final NamespaceRepository namespaceRepository;
|
||||
private final ReviewPermissionChecker permissionChecker;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
private final EntityManager entityManager;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public ReviewService(ReviewTaskRepository reviewTaskRepository,
|
||||
SkillVersionRepository skillVersionRepository,
|
||||
|
|
@ -41,14 +41,14 @@ public class ReviewService {
|
|||
NamespaceRepository namespaceRepository,
|
||||
ReviewPermissionChecker permissionChecker,
|
||||
ApplicationEventPublisher eventPublisher,
|
||||
EntityManager entityManager) {
|
||||
ObjectMapper objectMapper) {
|
||||
this.reviewTaskRepository = reviewTaskRepository;
|
||||
this.skillVersionRepository = skillVersionRepository;
|
||||
this.skillRepository = skillRepository;
|
||||
this.namespaceRepository = namespaceRepository;
|
||||
this.permissionChecker = permissionChecker;
|
||||
this.eventPublisher = eventPublisher;
|
||||
this.entityManager = entityManager;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
|
|
@ -58,6 +58,7 @@ public class ReviewService {
|
|||
Set<String> platformRoles) {
|
||||
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()));
|
||||
|
||||
|
|
@ -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
|
||||
public ReviewTask approveReview(Long reviewTaskId, String reviewerId, String comment,
|
||||
Map<Long, NamespaceRole> userNamespaceRoles,
|
||||
|
|
@ -104,11 +134,6 @@ public class ReviewService {
|
|||
if (updated == 0) {
|
||||
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())
|
||||
.orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", task.getSkillVersionId()));
|
||||
|
|
@ -119,12 +144,15 @@ public class ReviewService {
|
|||
Skill skill = skillRepository.findById(skillVersion.getSkillId())
|
||||
.orElseThrow(() -> new DomainNotFoundException("skill.not_found", skillVersion.getSkillId()));
|
||||
skill.setLatestVersionId(skillVersion.getId());
|
||||
applyPublishedMetadata(skill, skillVersion);
|
||||
skill.setUpdatedBy(reviewerId);
|
||||
skillRepository.save(skill);
|
||||
|
||||
eventPublisher.publishEvent(new SkillPublishedEvent(
|
||||
skill.getId(), skillVersion.getId(), reviewerId));
|
||||
|
||||
return task;
|
||||
// Reload to return updated state
|
||||
return reviewTaskRepository.findById(reviewTaskId).orElse(task);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
|
|
@ -151,18 +179,13 @@ public class ReviewService {
|
|||
if (updated == 0) {
|
||||
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())
|
||||
.orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", task.getSkillVersionId()));
|
||||
skillVersion.setStatus(SkillVersionStatus.REJECTED);
|
||||
skillVersionRepository.save(skillVersion);
|
||||
|
||||
return task;
|
||||
return reviewTaskRepository.findById(reviewTaskId).orElse(task);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
|
|
@ -198,4 +221,19 @@ public class ReviewService {
|
|||
Set<String> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,6 +57,10 @@ public class SkillTagService {
|
|||
return tags;
|
||||
}
|
||||
|
||||
public List<SkillTag> listTags(String namespaceSlug, String skillSlug) {
|
||||
return listTags(namespaceSlug, skillSlug, null, java.util.Map.of());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public SkillTag createOrMoveTag(
|
||||
String namespaceSlug,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,8 +3,6 @@ package com.iflytek.skillhub.domain.skill.validation;
|
|||
import com.iflytek.skillhub.domain.shared.exception.LocalizedDomainException;
|
||||
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.HashSet;
|
||||
import java.util.List;
|
||||
|
|
@ -12,13 +10,6 @@ import java.util.Set;
|
|||
|
||||
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 int maxFileCount;
|
||||
private final long maxSingleFileSize;
|
||||
|
|
@ -26,7 +17,13 @@ public class SkillPackageValidator {
|
|||
private final Set<String> allowedExtensions;
|
||||
|
||||
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,
|
||||
|
|
@ -45,33 +42,38 @@ public class SkillPackageValidator {
|
|||
|
||||
public ValidationResult validate(List<PackageEntry> entries) {
|
||||
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) {
|
||||
String normalizedPath = validateAndNormalizePath(entry.path(), errors);
|
||||
if (normalizedPath != null && !seenPaths.add(normalizedPath)) {
|
||||
errors.add("Duplicate file path: " + normalizedPath);
|
||||
String normalizedPath;
|
||||
try {
|
||||
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
|
||||
PackageEntry skillMd = entries.stream()
|
||||
.filter(e -> e.path().equals(SKILL_MD_PATH))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
|
||||
// 1. Check SKILL.md exists at root
|
||||
if (skillMd == null) {
|
||||
errors.add("Missing required file: SKILL.md at root");
|
||||
return ValidationResult.fail(errors);
|
||||
}
|
||||
|
||||
// 4. Validate frontmatter
|
||||
// 2. Validate frontmatter
|
||||
try {
|
||||
String content = new String(skillMd.content());
|
||||
metadataParser.parse(content);
|
||||
|
|
@ -82,23 +84,19 @@ public class SkillPackageValidator {
|
|||
errors.add("Invalid SKILL.md frontmatter: " + detail);
|
||||
}
|
||||
|
||||
// 5. Check file extensions
|
||||
for (PackageEntry entry : entries) {
|
||||
String path = entry.path().toLowerCase();
|
||||
boolean hasAllowedExtension = allowedExtensions.stream().anyMatch(path::endsWith);
|
||||
if (!hasAllowedExtension) {
|
||||
errors.add("Disallowed file extension: " + path);
|
||||
}
|
||||
// 3. Check file count
|
||||
if (entries.size() > maxFileCount) {
|
||||
errors.add("Too many files: " + entries.size() + " (max: " + maxFileCount + ")");
|
||||
}
|
||||
|
||||
// 6. Check single file size
|
||||
// 4. Check single file size
|
||||
for (PackageEntry entry : entries) {
|
||||
if (entry.size() > 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();
|
||||
if (totalSize > 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);
|
||||
}
|
||||
|
||||
private String validateAndNormalizePath(String path, List<String> errors) {
|
||||
if (path == null || path.isBlank()) {
|
||||
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;
|
||||
}
|
||||
private boolean hasAllowedExtension(String normalizedPath) {
|
||||
return allowedExtensions.stream().anyMatch(normalizedPath::endsWith);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ package com.iflytek.skillhub.domain.review;
|
|||
|
||||
import com.iflytek.skillhub.domain.event.SkillPublishedEvent;
|
||||
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.NamespaceType;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
|
|
@ -17,10 +16,10 @@ import org.mockito.ArgumentCaptor;
|
|||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import jakarta.persistence.EntityManager;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
|
@ -35,7 +34,6 @@ class PromotionServiceTest {
|
|||
@Mock private NamespaceRepository namespaceRepository;
|
||||
@Mock private ReviewPermissionChecker permissionChecker;
|
||||
@Mock private ApplicationEventPublisher eventPublisher;
|
||||
@Mock private EntityManager entityManager;
|
||||
|
||||
private PromotionService promotionService;
|
||||
|
||||
|
|
@ -52,7 +50,7 @@ class PromotionServiceTest {
|
|||
void setUp() {
|
||||
promotionService = new PromotionService(
|
||||
promotionRequestRepository, skillRepository, skillVersionRepository,
|
||||
skillFileRepository, namespaceRepository, permissionChecker, eventPublisher, entityManager);
|
||||
skillFileRepository, namespaceRepository, permissionChecker, eventPublisher);
|
||||
}
|
||||
|
||||
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(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(promotionRequestRepository.findBySourceVersionIdAndStatus(SOURCE_VERSION_ID, ReviewTaskStatus.PENDING))
|
||||
.thenReturn(Optional.empty());
|
||||
|
|
@ -135,8 +133,7 @@ class PromotionServiceTest {
|
|||
});
|
||||
|
||||
PromotionRequest result = promotionService.submitPromotion(
|
||||
SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID,
|
||||
Map.of(5L, NamespaceRole.OWNER), Set.of());
|
||||
SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID, Map.of());
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(SOURCE_SKILL_ID, result.getSourceSkillId());
|
||||
|
|
@ -151,7 +148,7 @@ class PromotionServiceTest {
|
|||
when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.empty());
|
||||
|
||||
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
|
||||
|
|
@ -160,7 +157,7 @@ class PromotionServiceTest {
|
|||
when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.empty());
|
||||
|
||||
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
|
||||
|
|
@ -173,7 +170,7 @@ class PromotionServiceTest {
|
|||
when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(sv));
|
||||
|
||||
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
|
||||
|
|
@ -186,42 +183,99 @@ class PromotionServiceTest {
|
|||
when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(sv));
|
||||
|
||||
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 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(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());
|
||||
|
||||
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
|
||||
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(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()));
|
||||
|
||||
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 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(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(promotionRequestRepository.findBySourceVersionIdAndStatus(SOURCE_VERSION_ID, ReviewTaskStatus.PENDING))
|
||||
.thenReturn(Optional.of(createPendingPromotion()));
|
||||
|
||||
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(
|
||||
PROMOTION_ID, ReviewTaskStatus.APPROVED, REVIEWER_ID, "LGTM", null, pr.getVersion()))
|
||||
.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(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(sourceVersion));
|
||||
when(skillRepository.save(any(Skill.class))).thenAnswer(inv -> {
|
||||
|
|
@ -302,9 +353,8 @@ class PromotionServiceTest {
|
|||
assertEquals(NEW_VERSION_ID, event.versionId());
|
||||
assertEquals(REVIEWER_ID, event.publisherId());
|
||||
|
||||
verify(entityManager).detach(pr);
|
||||
verify(promotionRequestRepository).updateStatusWithVersion(
|
||||
PROMOTION_ID, ReviewTaskStatus.APPROVED, REVIEWER_ID, "LGTM", NEW_SKILL_ID, pr.getVersion() + 1);
|
||||
// Verify targetSkillId updated on promotion request
|
||||
verify(promotionRequestRepository).save(pr);
|
||||
assertEquals(NEW_SKILL_ID, pr.getTargetSkillId());
|
||||
}
|
||||
|
||||
|
|
@ -357,9 +407,6 @@ class PromotionServiceTest {
|
|||
when(promotionRequestRepository.findById(PROMOTION_ID)).thenReturn(Optional.of(pr));
|
||||
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(
|
||||
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(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(sourceVersion));
|
||||
when(skillRepository.save(any(Skill.class))).thenAnswer(inv -> {
|
||||
|
|
@ -396,15 +443,14 @@ class PromotionServiceTest {
|
|||
when(promotionRequestRepository.updateStatusWithVersion(
|
||||
PROMOTION_ID, ReviewTaskStatus.REJECTED, REVIEWER_ID, "Not ready", null, pr.getVersion()))
|
||||
.thenReturn(1);
|
||||
when(promotionRequestRepository.findById(PROMOTION_ID)).thenReturn(Optional.of(pr));
|
||||
|
||||
PromotionRequest result = promotionService.rejectPromotion(
|
||||
PROMOTION_ID, REVIEWER_ID, "Not ready", Set.of("SKILL_ADMIN"));
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(ReviewTaskStatus.REJECTED, result.getStatus());
|
||||
assertEquals(REVIEWER_ID, result.getReviewedBy());
|
||||
verify(promotionRequestRepository).updateStatusWithVersion(
|
||||
PROMOTION_ID, ReviewTaskStatus.REJECTED, REVIEWER_ID, "Not ready", null, pr.getVersion());
|
||||
verify(entityManager).detach(pr);
|
||||
verify(eventPublisher, never()).publishEvent(any());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ package com.iflytek.skillhub.domain.review;
|
|||
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceType;
|
||||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Map;
|
||||
|
|
@ -24,18 +26,18 @@ class ReviewPermissionCheckerTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
void skillAdminCanReviewOwnSubmission() {
|
||||
void skillAdminCannotReviewOwnSubmission() {
|
||||
String userId = "user-1";
|
||||
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")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void superAdminCanReviewOwnSubmission() {
|
||||
void superAdminCannotReviewOwnSubmission() {
|
||||
String userId = "user-1";
|
||||
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")));
|
||||
}
|
||||
|
||||
|
|
@ -105,6 +107,53 @@ class ReviewPermissionCheckerTest {
|
|||
|
||||
// --- 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
|
||||
void skillAdminCanReviewPromotion() {
|
||||
PromotionRequest req = new PromotionRequest(1L, 1L, 1L, "user-2");
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.iflytek.skillhub.domain.review;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.iflytek.skillhub.domain.event.SkillPublishedEvent;
|
||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
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.SkillVersionStatus;
|
||||
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.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
|
@ -42,7 +43,6 @@ class ReviewServiceTest {
|
|||
@Mock private NamespaceRepository namespaceRepository;
|
||||
@Mock private ReviewPermissionChecker permissionChecker;
|
||||
@Mock private ApplicationEventPublisher eventPublisher;
|
||||
@Mock private EntityManager entityManager;
|
||||
|
||||
private ReviewService reviewService;
|
||||
|
||||
|
|
@ -52,12 +52,14 @@ class ReviewServiceTest {
|
|||
private static final String REVIEWER_ID = "user-200";
|
||||
private static final Long REVIEW_TASK_ID = 1L;
|
||||
private static final Long SKILL_ID = 30L;
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
objectMapper = new ObjectMapper();
|
||||
reviewService = new ReviewService(
|
||||
reviewTaskRepository, skillVersionRepository, skillRepository,
|
||||
namespaceRepository, permissionChecker, eventPublisher, entityManager);
|
||||
namespaceRepository, permissionChecker, eventPublisher, objectMapper);
|
||||
}
|
||||
|
||||
private SkillVersion createDraftSkillVersion() {
|
||||
|
|
@ -109,11 +111,17 @@ class ReviewServiceTest {
|
|||
Skill skill = createSkill();
|
||||
when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv));
|
||||
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();
|
||||
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);
|
||||
assertEquals(SkillVersionStatus.PENDING_REVIEW, sv.getStatus());
|
||||
|
|
@ -126,19 +134,17 @@ class ReviewServiceTest {
|
|||
when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.empty());
|
||||
|
||||
assertThrows(DomainNotFoundException.class,
|
||||
() -> reviewService.submitReview(SKILL_VERSION_ID, USER_ID, Map.of(), Set.of()));
|
||||
() -> reviewService.submitReview(SKILL_VERSION_ID, USER_ID, Map.of()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldThrowWhenStatusNotDraft() {
|
||||
SkillVersion sv = createPendingReviewSkillVersion();
|
||||
Skill skill = createSkill();
|
||||
when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv));
|
||||
when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill));
|
||||
when(permissionChecker.canSubmitForReview(eq(skill), eq(USER_ID), anyMap(), anySet())).thenReturn(true);
|
||||
when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(createSkill()));
|
||||
|
||||
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
|
||||
|
|
@ -147,12 +153,31 @@ class ReviewServiceTest {
|
|||
Skill skill = createSkill();
|
||||
when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv));
|
||||
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)))
|
||||
.thenThrow(new DataIntegrityViolationException("duplicate"));
|
||||
|
||||
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();
|
||||
SkillVersion sv = createPendingReviewSkillVersion();
|
||||
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(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(ns));
|
||||
|
|
@ -175,19 +206,19 @@ class ReviewServiceTest {
|
|||
.thenReturn(1);
|
||||
when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv));
|
||||
when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill));
|
||||
when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task));
|
||||
|
||||
ReviewTask result = reviewService.approveReview(
|
||||
REVIEW_TASK_ID, REVIEWER_ID, "LGTM",
|
||||
Map.of(NAMESPACE_ID, NamespaceRole.ADMIN), Set.of());
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(ReviewTaskStatus.APPROVED, result.getStatus());
|
||||
assertEquals(REVIEWER_ID, result.getReviewedBy());
|
||||
assertEquals("LGTM", result.getReviewComment());
|
||||
assertNotNull(result.getReviewedAt());
|
||||
assertEquals(SkillVersionStatus.PUBLISHED, sv.getStatus());
|
||||
assertNotNull(sv.getPublishedAt());
|
||||
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));
|
||||
}
|
||||
|
||||
|
|
@ -204,6 +235,7 @@ class ReviewServiceTest {
|
|||
when(reviewTaskRepository.updateStatusWithVersion(any(), any(), any(), any(), any())).thenReturn(1);
|
||||
when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv));
|
||||
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",
|
||||
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(reviewTaskRepository.updateStatusWithVersion(any(), any(), any(), any(), any())).thenReturn(1);
|
||||
when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv));
|
||||
when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task));
|
||||
|
||||
ReviewTask result = reviewService.rejectReview(
|
||||
REVIEW_TASK_ID, REVIEWER_ID, "needs work",
|
||||
Map.of(NAMESPACE_ID, NamespaceRole.ADMIN), Set.of());
|
||||
|
||||
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());
|
||||
verify(skillVersionRepository).save(sv);
|
||||
verify(eventPublisher, never()).publishEvent(any(SkillPublishedEvent.class));
|
||||
|
|
|
|||
|
|
@ -176,4 +176,49 @@ class SkillPackageValidatorTest {
|
|||
assertFalse(result.passed());
|
||||
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")));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,13 +6,14 @@ import com.iflytek.skillhub.domain.user.UserStatus;
|
|||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public interface UserAccountJpaRepository
|
||||
extends JpaRepository<UserAccount, String>, UserAccountRepository {
|
||||
extends JpaRepository<UserAccount, String>, JpaSpecificationExecutor<UserAccount>, UserAccountRepository {
|
||||
|
||||
@Override
|
||||
@Query("""
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ public class LocalFileStorageService implements ObjectStorageService {
|
|||
private Path resolve(String key) {
|
||||
Path resolved = basePath.resolve(key).normalize();
|
||||
if (!resolved.startsWith(basePath)) {
|
||||
throw new IllegalArgumentException("Resolved path escapes storage base path: " + key);
|
||||
throw new IllegalArgumentException("Invalid storage key: " + key);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,24 +1,110 @@
|
|||
package com.iflytek.skillhub.storage;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
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 {
|
||||
|
||||
@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
|
||||
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();
|
||||
properties.getLocal().setBasePath(tempDir.toString());
|
||||
Files.createDirectories(tempDir);
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 { Layout } from './layout'
|
||||
import { getCurrentUser } from '@/api/client'
|
||||
|
||||
function lazyRouteComponent<TModule extends Record<string, unknown>>(
|
||||
function createLazyRouteComponent<TModule extends Record<string, unknown>>(
|
||||
importer: () => Promise<TModule>,
|
||||
exportName: keyof TModule,
|
||||
) {
|
||||
const LazyComponent = lazy(async () => {
|
||||
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 LoginPage = lazyRouteComponent(() => import('@/pages/login'), 'LoginPage')
|
||||
const RegisterPage = lazyRouteComponent(() => import('@/pages/register'), 'RegisterPage')
|
||||
const PrivacyPolicyPage = lazyRouteComponent(() => import('@/pages/privacy'), 'PrivacyPolicyPage')
|
||||
const SearchPage = lazyRouteComponent(() => import('@/pages/search'), 'SearchPage')
|
||||
const TermsOfServicePage = lazyRouteComponent(() => import('@/pages/terms'), 'TermsOfServicePage')
|
||||
const NamespacePage = lazyRouteComponent(() => import('@/pages/namespace'), 'NamespacePage')
|
||||
const SkillDetailPage = lazyRouteComponent(() => import('@/pages/skill-detail'), 'SkillDetailPage')
|
||||
const DashboardPage = lazyRouteComponent(() => import('@/pages/dashboard'), 'DashboardPage')
|
||||
const MySkillsPage = lazyRouteComponent(() => import('@/pages/dashboard/my-skills'), 'MySkillsPage')
|
||||
const PublishPage = lazyRouteComponent(() => import('@/pages/dashboard/publish'), 'PublishPage')
|
||||
const MyNamespacesPage = lazyRouteComponent(() => import('@/pages/dashboard/my-namespaces'), 'MyNamespacesPage')
|
||||
const NamespaceMembersPage = lazyRouteComponent(() => import('@/pages/dashboard/namespace-members'), 'NamespaceMembersPage')
|
||||
const NamespaceReviewsPage = lazyRouteComponent(() => import('@/pages/dashboard/namespace-reviews'), 'NamespaceReviewsPage')
|
||||
const ReviewsPage = lazyRouteComponent(() => import('@/pages/dashboard/reviews'), 'ReviewsPage')
|
||||
const ReviewDetailPage = lazyRouteComponent(() => import('@/pages/dashboard/review-detail'), 'ReviewDetailPage')
|
||||
const PromotionsPage = lazyRouteComponent(() => import('@/pages/dashboard/promotions'), 'PromotionsPage')
|
||||
const MyStarsPage = lazyRouteComponent(() => import('@/pages/dashboard/stars'), 'MyStarsPage')
|
||||
const TokensPage = lazyRouteComponent(() => import('@/pages/dashboard/tokens'), 'TokensPage')
|
||||
const DeviceAuthPage = lazyRouteComponent(() => import('@/pages/device'), 'DeviceAuthPage')
|
||||
const SecuritySettingsPage = lazyRouteComponent(() => import('@/pages/settings/security'), 'SecuritySettingsPage')
|
||||
const AccountSettingsPage = lazyRouteComponent(() => import('@/pages/settings/accounts'), 'AccountSettingsPage')
|
||||
const AdminUsersPage = lazyRouteComponent(() => import('@/pages/admin/users'), 'AdminUsersPage')
|
||||
const AuditLogPage = lazyRouteComponent(() => import('@/pages/admin/audit-log'), 'AuditLogPage')
|
||||
const HomePage = createLazyRouteComponent(() => import('@/pages/home'), 'HomePage')
|
||||
const LoginPage = createLazyRouteComponent(() => import('@/pages/login'), 'LoginPage')
|
||||
const RegisterPage = createLazyRouteComponent(() => import('@/pages/register'), 'RegisterPage')
|
||||
const PrivacyPolicyPage = createLazyRouteComponent(() => import('@/pages/privacy'), 'PrivacyPolicyPage')
|
||||
const SearchPage = createLazyRouteComponent(() => import('@/pages/search'), 'SearchPage')
|
||||
const TermsOfServicePage = createLazyRouteComponent(() => import('@/pages/terms'), 'TermsOfServicePage')
|
||||
const NamespacePage = createLazyRouteComponent(() => import('@/pages/namespace'), 'NamespacePage')
|
||||
const SkillDetailPage = createLazyRouteComponent(() => import('@/pages/skill-detail'), 'SkillDetailPage')
|
||||
const DashboardPage = createLazyRouteComponent(() => import('@/pages/dashboard'), 'DashboardPage')
|
||||
const MySkillsPage = createLazyRouteComponent(() => import('@/pages/dashboard/my-skills'), 'MySkillsPage')
|
||||
const PublishPage = createLazyRouteComponent(() => import('@/pages/dashboard/publish'), 'PublishPage')
|
||||
const MyNamespacesPage = createLazyRouteComponent(
|
||||
() => import('@/pages/dashboard/my-namespaces'),
|
||||
'MyNamespacesPage',
|
||||
)
|
||||
const NamespaceMembersPage = createLazyRouteComponent(
|
||||
() => import('@/pages/dashboard/namespace-members'),
|
||||
'NamespaceMembersPage',
|
||||
)
|
||||
const NamespaceReviewsPage = createLazyRouteComponent(
|
||||
() => import('@/pages/dashboard/namespace-reviews'),
|
||||
'NamespaceReviewsPage',
|
||||
)
|
||||
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({
|
||||
component: Layout,
|
||||
|
|
|
|||
|
|
@ -2,51 +2,21 @@ import ReactMarkdown from 'react-markdown'
|
|||
import rehypeHighlight from 'rehype-highlight'
|
||||
import rehypeSanitize from 'rehype-sanitize'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { cn } from '@/shared/lib/utils'
|
||||
|
||||
interface MarkdownRendererProps {
|
||||
content: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
function stripFrontmatter(content: string) {
|
||||
return content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '')
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
className={cn(
|
||||
'max-w-none text-sm leading-7 text-foreground',
|
||||
'[&_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}
|
||||
<div className={containerClassName}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeSanitize, rehypeHighlight]}>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue