mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-27 11:14:59 +00:00
fix(security): close unauthorized metrics and compat access paths
This commit is contained in:
parent
c2981836bb
commit
441dc9e0e7
16 changed files with 425 additions and 41 deletions
|
|
@ -32,7 +32,7 @@ services:
|
|||
redis:
|
||||
image: ${REDIS_IMAGE:-redis:7-alpine}
|
||||
ports:
|
||||
- "6379:6379"
|
||||
- "127.0.0.1:6379:6379"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
|
|
|
|||
|
|
@ -94,7 +94,8 @@ public class ClawHubCompatAppService {
|
|||
String hash,
|
||||
String userId,
|
||||
Map<Long, NamespaceRole> userNsRoles) {
|
||||
SkillCoordinate coord = resolveQueryCoordinate(slug);
|
||||
SkillCoordinate coord = resolveQueryCoordinate(slug, userId, userNsRoles);
|
||||
Map<Long, NamespaceRole> roles = normalizeRoles(userNsRoles);
|
||||
|
||||
SkillQueryService.ResolvedVersionDTO resolved = skillQueryService.resolveVersion(
|
||||
coord.namespace(),
|
||||
|
|
@ -103,7 +104,7 @@ public class ClawHubCompatAppService {
|
|||
"latest".equals(version) ? "latest" : null,
|
||||
hash,
|
||||
userId,
|
||||
userNsRoles != null ? userNsRoles : Map.of()
|
||||
roles
|
||||
);
|
||||
return toResolveResponse(resolved);
|
||||
}
|
||||
|
|
@ -132,23 +133,37 @@ public class ClawHubCompatAppService {
|
|||
: "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/versions/" + version + "/download";
|
||||
}
|
||||
|
||||
public String downloadLocationByQuery(String slug, String version) {
|
||||
SkillCoordinate coord = resolveQueryCoordinate(slug);
|
||||
public String downloadLocationByQuery(String slug,
|
||||
String version,
|
||||
String userId,
|
||||
Map<Long, NamespaceRole> userNsRoles) {
|
||||
SkillCoordinate coord = resolveQueryCoordinate(slug, userId, userNsRoles);
|
||||
return "latest".equals(version)
|
||||
? "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/download"
|
||||
: "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/versions/" + version + "/download";
|
||||
}
|
||||
|
||||
private SkillCoordinate resolveQueryCoordinate(String slug) {
|
||||
private SkillCoordinate resolveQueryCoordinate(String slug,
|
||||
String userId,
|
||||
Map<Long, NamespaceRole> userNsRoles) {
|
||||
if (slug != null && slug.contains("--")) {
|
||||
return mapper.fromCanonical(slug);
|
||||
}
|
||||
CompatSkillLookupService.CompatSkillContext context;
|
||||
try {
|
||||
CompatSkillLookupService.CompatSkillContext context = compatSkillLookupService.findByLegacySlug(slug);
|
||||
return new SkillCoordinate(context.namespace().getSlug(), context.skill().getSlug());
|
||||
context = compatSkillLookupService.findByLegacySlug(slug);
|
||||
} catch (DomainNotFoundException ex) {
|
||||
return mapper.fromCanonical(slug);
|
||||
}
|
||||
Map<Long, NamespaceRole> roles = normalizeRoles(userNsRoles);
|
||||
if (!compatSkillLookupService.canAccess(context.skill(), userId, roles)) {
|
||||
throw new DomainNotFoundException("error.skill.notFound", slug);
|
||||
}
|
||||
return new SkillCoordinate(context.namespace().getSlug(), context.skill().getSlug());
|
||||
}
|
||||
|
||||
private Map<Long, NamespaceRole> normalizeRoles(Map<Long, NamespaceRole> userNsRoles) {
|
||||
return userNsRoles != null ? userNsRoles : Map.of();
|
||||
}
|
||||
|
||||
public ClawHubSkillListResponse listSkills(int page,
|
||||
|
|
@ -182,11 +197,18 @@ public class ClawHubCompatAppService {
|
|||
}
|
||||
|
||||
public ClawHubSkillResponse getSkill(String canonicalSlug, String userId) {
|
||||
return getSkill(canonicalSlug, userId, Map.of());
|
||||
}
|
||||
|
||||
public ClawHubSkillResponse getSkill(String canonicalSlug,
|
||||
String userId,
|
||||
Map<Long, NamespaceRole> userNsRoles) {
|
||||
SkillCoordinate coord = mapper.fromCanonical(canonicalSlug);
|
||||
CompatSkillLookupService.CompatSkillContext context = compatSkillLookupService.resolveVisible(
|
||||
coord.namespace(),
|
||||
coord.slug(),
|
||||
userId
|
||||
userId,
|
||||
userNsRoles != null ? userNsRoles : Map.of()
|
||||
);
|
||||
SkillVersion latestVersionEntity = context.latestVersion().orElse(null);
|
||||
|
||||
|
|
|
|||
|
|
@ -82,8 +82,10 @@ public class ClawHubCompatController {
|
|||
@RateLimit(category = "download", authenticated = 60, anonymous = 20)
|
||||
@GetMapping("/download")
|
||||
public ResponseEntity<Void> downloadByQuery(@RequestParam String slug,
|
||||
@RequestParam(defaultValue = "latest") String version) {
|
||||
return redirect(clawHubCompatAppService.downloadLocationByQuery(slug, version));
|
||||
@RequestParam(defaultValue = "latest") String version,
|
||||
@RequestAttribute(value = "userId", required = false) String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
|
||||
return redirect(clawHubCompatAppService.downloadLocationByQuery(slug, version, userId, userNsRoles));
|
||||
}
|
||||
|
||||
@RateLimit(category = "skills", authenticated = 60, anonymous = 20)
|
||||
|
|
@ -99,8 +101,9 @@ public class ClawHubCompatController {
|
|||
@RateLimit(category = "skills", authenticated = 60, anonymous = 20)
|
||||
@GetMapping("/skills/{canonicalSlug}")
|
||||
public ClawHubSkillResponse getSkill(@PathVariable String canonicalSlug,
|
||||
@RequestAttribute(value = "userId", required = false) String userId) {
|
||||
return clawHubCompatAppService.getSkill(canonicalSlug, userId);
|
||||
@RequestAttribute(value = "userId", required = false) String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
|
||||
return clawHubCompatAppService.getSkill(canonicalSlug, userId, userNsRoles);
|
||||
}
|
||||
|
||||
@RateLimit(category = "skills", authenticated = 60, anonymous = 20)
|
||||
|
|
|
|||
|
|
@ -83,7 +83,8 @@ public class ClawHubRegistryFacade {
|
|||
CompatSkillLookupService.CompatSkillContext context = compatSkillLookupService.resolveVisible(
|
||||
coordinate.namespace(),
|
||||
coordinate.slug(),
|
||||
userId
|
||||
userId,
|
||||
normalizeRoles(userNsRoles)
|
||||
);
|
||||
Skill skill = context.skill();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
package com.iflytek.skillhub.compat;
|
||||
|
||||
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.shared.exception.DomainNotFoundException;
|
||||
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.VisibilityChecker;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillSlugResolutionService;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
|
|
@ -22,15 +25,18 @@ public class CompatSkillLookupService {
|
|||
private final NamespaceRepository namespaceRepository;
|
||||
private final SkillVersionRepository skillVersionRepository;
|
||||
private final SkillSlugResolutionService skillSlugResolutionService;
|
||||
private final VisibilityChecker visibilityChecker;
|
||||
|
||||
public CompatSkillLookupService(SkillRepository skillRepository,
|
||||
NamespaceRepository namespaceRepository,
|
||||
SkillVersionRepository skillVersionRepository,
|
||||
SkillSlugResolutionService skillSlugResolutionService) {
|
||||
SkillSlugResolutionService skillSlugResolutionService,
|
||||
VisibilityChecker visibilityChecker) {
|
||||
this.skillRepository = skillRepository;
|
||||
this.namespaceRepository = namespaceRepository;
|
||||
this.skillVersionRepository = skillVersionRepository;
|
||||
this.skillSlugResolutionService = skillSlugResolutionService;
|
||||
this.visibilityChecker = visibilityChecker;
|
||||
}
|
||||
|
||||
public CompatSkillContext findByLegacySlug(String slug) {
|
||||
|
|
@ -41,10 +47,28 @@ public class CompatSkillLookupService {
|
|||
return new CompatSkillContext(namespace, skill, findLatestVersion(skill));
|
||||
}
|
||||
|
||||
public boolean canAccess(Skill skill, String currentUserId, Map<Long, NamespaceRole> userNsRoles) {
|
||||
if (skill == null) {
|
||||
return false;
|
||||
}
|
||||
Map<Long, NamespaceRole> roles = userNsRoles != null ? userNsRoles : Map.of();
|
||||
return visibilityChecker.canAccess(skill, currentUserId, roles);
|
||||
}
|
||||
|
||||
public CompatSkillContext resolveVisible(String namespaceSlug, String skillSlug, String currentUserId) {
|
||||
return resolveVisible(namespaceSlug, skillSlug, currentUserId, Map.of());
|
||||
}
|
||||
|
||||
public CompatSkillContext resolveVisible(String namespaceSlug,
|
||||
String skillSlug,
|
||||
String currentUserId,
|
||||
Map<Long, NamespaceRole> userNsRoles) {
|
||||
Namespace namespace = namespaceRepository.findBySlug(namespaceSlug)
|
||||
.orElseThrow(() -> new DomainNotFoundException("error.namespace.notFound", namespaceSlug));
|
||||
Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId);
|
||||
if (!canAccess(skill, currentUserId, userNsRoles)) {
|
||||
throw new DomainNotFoundException("error.skill.notFound", skillSlug);
|
||||
}
|
||||
return new CompatSkillContext(namespace, skill, findLatestVersion(skill));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -55,8 +55,10 @@ public class NamespaceController extends BaseApiController {
|
|||
}
|
||||
|
||||
@GetMapping("/namespaces")
|
||||
public ApiResponse<PageResponse<NamespaceResponse>> listNamespaces(Pageable pageable) {
|
||||
return ok("response.success.read", namespacePortalQueryAppService.listNamespaces(pageable));
|
||||
public ApiResponse<PageResponse<NamespaceResponse>> listNamespaces(
|
||||
Pageable pageable,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
|
||||
return ok("response.success.read", namespacePortalQueryAppService.listNamespaces(pageable, userNsRoles));
|
||||
}
|
||||
|
||||
@GetMapping("/me/namespaces")
|
||||
|
|
@ -68,7 +70,7 @@ public class NamespaceController extends BaseApiController {
|
|||
|
||||
@GetMapping("/namespaces/{slug}")
|
||||
public ApiResponse<NamespaceResponse> getNamespace(@PathVariable String slug,
|
||||
@RequestAttribute(value = "userId", required = false) String userId,
|
||||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
|
||||
return ok("response.success.read",
|
||||
namespacePortalQueryAppService.getNamespace(slug, userId, userNsRoles));
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
|
|||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceService;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
|
||||
import com.iflytek.skillhub.dto.MemberResponse;
|
||||
import com.iflytek.skillhub.dto.MyNamespaceResponse;
|
||||
import com.iflytek.skillhub.dto.NamespaceResponse;
|
||||
|
|
@ -16,6 +17,8 @@ import java.util.Comparator;
|
|||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
|
@ -43,9 +46,31 @@ public class NamespacePortalQueryAppService {
|
|||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PageResponse<NamespaceResponse> listNamespaces(Pageable pageable) {
|
||||
Page<Namespace> namespaces = namespaceRepository.findByStatus(NamespaceStatus.ACTIVE, pageable);
|
||||
return PageResponse.from(namespaces.map(NamespaceResponse::from));
|
||||
public PageResponse<NamespaceResponse> listNamespaces(Pageable pageable, Map<Long, NamespaceRole> userNamespaceRoles) {
|
||||
Map<Long, NamespaceRole> namespaceRoles = userNamespaceRoles != null ? userNamespaceRoles : Map.of();
|
||||
if (namespaceRoles.isEmpty()) {
|
||||
Page<NamespaceResponse> empty = new PageImpl<>(
|
||||
List.of(),
|
||||
PageRequest.of(pageable.getPageNumber(), pageable.getPageSize()),
|
||||
0
|
||||
);
|
||||
return PageResponse.from(empty);
|
||||
}
|
||||
|
||||
List<Namespace> scopedNamespaces = namespaceRepository.findByIdIn(namespaceRoles.keySet().stream().toList()).stream()
|
||||
.filter(namespace -> namespace.getStatus() == NamespaceStatus.ACTIVE)
|
||||
.sorted(Comparator.comparing(Namespace::getSlug))
|
||||
.toList();
|
||||
int fromIndex = Math.min((int) pageable.getOffset(), scopedNamespaces.size());
|
||||
int toIndex = Math.min(fromIndex + pageable.getPageSize(), scopedNamespaces.size());
|
||||
Page<NamespaceResponse> page = new PageImpl<>(
|
||||
scopedNamespaces.subList(fromIndex, toIndex).stream()
|
||||
.map(NamespaceResponse::from)
|
||||
.toList(),
|
||||
pageable,
|
||||
scopedNamespaces.size()
|
||||
);
|
||||
return PageResponse.from(page);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
|
|
@ -66,10 +91,14 @@ public class NamespacePortalQueryAppService {
|
|||
|
||||
@Transactional(readOnly = true)
|
||||
public NamespaceResponse getNamespace(String slug, String userId, Map<Long, NamespaceRole> userNamespaceRoles) {
|
||||
Map<Long, NamespaceRole> namespaceRoles = userNamespaceRoles != null ? userNamespaceRoles : Map.of();
|
||||
Namespace namespace = namespaceService.getNamespaceBySlugForRead(
|
||||
slug,
|
||||
userId,
|
||||
userNamespaceRoles != null ? userNamespaceRoles : Map.of());
|
||||
namespaceRoles);
|
||||
if (!namespaceRoles.containsKey(namespace.getId())) {
|
||||
throw new DomainForbiddenException("error.namespace.membership.required");
|
||||
}
|
||||
return NamespaceResponse.from(namespace);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -171,7 +171,7 @@ management:
|
|||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info,prometheus,metrics
|
||||
include: health,info
|
||||
endpoint:
|
||||
health:
|
||||
show-details: when-authorized
|
||||
|
|
@ -180,4 +180,4 @@ management:
|
|||
application: skillhub
|
||||
export:
|
||||
prometheus:
|
||||
enabled: true
|
||||
enabled: false
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
package com.iflytek.skillhub.compat;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.iflytek.skillhub.controller.support.MultipartPackageExtractor;
|
||||
import com.iflytek.skillhub.controller.support.ZipPackageExtractor;
|
||||
import com.iflytek.skillhub.domain.audit.AuditLogService;
|
||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
|
||||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillQueryService;
|
||||
import com.iflytek.skillhub.domain.social.SkillStarService;
|
||||
import com.iflytek.skillhub.service.SkillSearchAppService;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ClawHubCompatAppServiceTest {
|
||||
|
||||
private final SkillSearchAppService skillSearchAppService = mock(SkillSearchAppService.class);
|
||||
private final SkillQueryService skillQueryService = mock(SkillQueryService.class);
|
||||
private final SkillPublishService skillPublishService = mock(SkillPublishService.class);
|
||||
private final ZipPackageExtractor zipPackageExtractor = mock(ZipPackageExtractor.class);
|
||||
private final MultipartPackageExtractor multipartPackageExtractor = mock(MultipartPackageExtractor.class);
|
||||
private final AuditLogService auditLogService = mock(AuditLogService.class);
|
||||
private final CompatSkillLookupService compatSkillLookupService = mock(CompatSkillLookupService.class);
|
||||
private final SkillStarService skillStarService = mock(SkillStarService.class);
|
||||
|
||||
private final ClawHubCompatAppService service = new ClawHubCompatAppService(
|
||||
new CanonicalSlugMapper(),
|
||||
skillSearchAppService,
|
||||
skillQueryService,
|
||||
skillPublishService,
|
||||
zipPackageExtractor,
|
||||
multipartPackageExtractor,
|
||||
auditLogService,
|
||||
compatSkillLookupService,
|
||||
skillStarService
|
||||
);
|
||||
|
||||
@Test
|
||||
void downloadLocationByQuery_throwsNotFound_whenLegacySkillIsPrivateForAnonymousCaller() {
|
||||
Namespace namespace = new Namespace("team-a", "Team A", "owner-1");
|
||||
Skill privateSkill = new Skill(1L, "priv", "owner-1", SkillVisibility.PRIVATE);
|
||||
CompatSkillLookupService.CompatSkillContext context = new CompatSkillLookupService.CompatSkillContext(
|
||||
namespace,
|
||||
privateSkill,
|
||||
Optional.empty()
|
||||
);
|
||||
|
||||
when(compatSkillLookupService.findByLegacySlug("priv")).thenReturn(context);
|
||||
when(compatSkillLookupService.canAccess(privateSkill, null, Map.of())).thenReturn(false);
|
||||
|
||||
assertThatThrownBy(() -> service.downloadLocationByQuery("priv", "latest", null, null))
|
||||
.isInstanceOf(DomainNotFoundException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void downloadLocationByQuery_returnsCanonicalPath_whenLegacySkillIsVisible() {
|
||||
Namespace namespace = new Namespace("team-a", "Team A", "owner-1");
|
||||
Skill publicSkill = new Skill(1L, "my-skill", "owner-1", SkillVisibility.PUBLIC);
|
||||
CompatSkillLookupService.CompatSkillContext context = new CompatSkillLookupService.CompatSkillContext(
|
||||
namespace,
|
||||
publicSkill,
|
||||
Optional.empty()
|
||||
);
|
||||
|
||||
when(compatSkillLookupService.findByLegacySlug("my-skill")).thenReturn(context);
|
||||
when(compatSkillLookupService.canAccess(publicSkill, null, Map.of())).thenReturn(true);
|
||||
|
||||
String location = service.downloadLocationByQuery("my-skill", "latest", null, null);
|
||||
|
||||
assertThat(location).isEqualTo("/api/v1/skills/team-a/my-skill/download");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
package com.iflytek.skillhub.compat;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.verify;
|
||||
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;
|
||||
|
||||
import com.iflytek.skillhub.auth.device.DeviceAuthService;
|
||||
import com.iflytek.skillhub.compat.dto.ClawHubSkillResponse;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
|
||||
import java.util.Map;
|
||||
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;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles("test")
|
||||
class ClawHubCompatControllerSecurityTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@MockBean
|
||||
private NamespaceMemberRepository namespaceMemberRepository;
|
||||
|
||||
@MockBean
|
||||
private DeviceAuthService deviceAuthService;
|
||||
|
||||
@MockBean
|
||||
private ClawHubCompatAppService clawHubCompatAppService;
|
||||
|
||||
@Test
|
||||
void getSkill_returnsNotFound_whenAnonymousCannotAccessPrivateSkill() throws Exception {
|
||||
when(clawHubCompatAppService.getSkill(eq("priv"), isNull(), isNull()))
|
||||
.thenThrow(new DomainNotFoundException("error.skill.notFound", "priv"));
|
||||
|
||||
mockMvc.perform(get("/api/v1/skills/priv"))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSkill_returnsSkill_whenCallerHasNamespacePermission() throws Exception {
|
||||
var roles = Map.of(1L, NamespaceRole.ADMIN);
|
||||
var response = new ClawHubSkillResponse(
|
||||
new ClawHubSkillResponse.SkillInfo(
|
||||
"team-ai--priv",
|
||||
"Private Skill",
|
||||
"summary",
|
||||
Map.of(),
|
||||
Map.of(),
|
||||
0L,
|
||||
0L
|
||||
),
|
||||
null,
|
||||
null,
|
||||
new ClawHubSkillResponse.ModerationInfo(false, false, "clean", new String[0], null, null, null)
|
||||
);
|
||||
when(clawHubCompatAppService.getSkill("team-ai--priv", "admin-1", roles)).thenReturn(response);
|
||||
|
||||
mockMvc.perform(get("/api/v1/skills/team-ai--priv")
|
||||
.requestAttr("userId", "admin-1")
|
||||
.requestAttr("userNsRoles", roles))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.skill.slug").value("team-ai--priv"));
|
||||
|
||||
verify(clawHubCompatAppService).getSkill("team-ai--priv", "admin-1", roles);
|
||||
}
|
||||
|
||||
@Test
|
||||
void downloadQuery_returnsNotFound_whenAnonymousCannotAccessPrivateLegacySlug() throws Exception {
|
||||
when(clawHubCompatAppService.downloadLocationByQuery(eq("priv"), eq("latest"), isNull(), isNull()))
|
||||
.thenThrow(new DomainNotFoundException("error.skill.notFound", "priv"));
|
||||
|
||||
mockMvc.perform(get("/api/v1/download")
|
||||
.param("slug", "priv")
|
||||
.param("version", "latest"))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
void downloadQuery_returnsNotFound_whenUserWithoutNamespaceRoleAccessesPrivateLegacySlug() throws Exception {
|
||||
when(clawHubCompatAppService.downloadLocationByQuery("priv", "latest", "user-1", Map.of()))
|
||||
.thenThrow(new DomainNotFoundException("error.skill.notFound", "priv"));
|
||||
|
||||
mockMvc.perform(get("/api/v1/download")
|
||||
.param("slug", "priv")
|
||||
.param("version", "latest")
|
||||
.requestAttr("userId", "user-1")
|
||||
.requestAttr("userNsRoles", Map.of()))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
package com.iflytek.skillhub.compat;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
|
||||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.skill.SkillRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
||||
import com.iflytek.skillhub.domain.skill.VisibilityChecker;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillSlugResolutionService;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
class CompatSkillLookupServiceTest {
|
||||
|
||||
private final SkillRepository skillRepository = mock(SkillRepository.class);
|
||||
private final NamespaceRepository namespaceRepository = mock(NamespaceRepository.class);
|
||||
private final SkillVersionRepository skillVersionRepository = mock(SkillVersionRepository.class);
|
||||
private final SkillSlugResolutionService skillSlugResolutionService = mock(SkillSlugResolutionService.class);
|
||||
private final VisibilityChecker visibilityChecker = mock(VisibilityChecker.class);
|
||||
|
||||
private final CompatSkillLookupService service = new CompatSkillLookupService(
|
||||
skillRepository,
|
||||
namespaceRepository,
|
||||
skillVersionRepository,
|
||||
skillSlugResolutionService,
|
||||
visibilityChecker
|
||||
);
|
||||
|
||||
@Test
|
||||
void resolveVisible_throwsNotFoundWhenCallerCannotAccessSkill() {
|
||||
Namespace namespace = new Namespace("team-a", "Team A", "owner-1");
|
||||
ReflectionTestUtils.setField(namespace, "id", 1L);
|
||||
Skill privateSkill = new Skill(1L, "priv", "owner-1", SkillVisibility.PRIVATE);
|
||||
ReflectionTestUtils.setField(privateSkill, "id", 7L);
|
||||
privateSkill.setLatestVersionId(70L);
|
||||
|
||||
when(namespaceRepository.findBySlug("team-a")).thenReturn(Optional.of(namespace));
|
||||
when(skillSlugResolutionService.resolve(1L, "priv", null, SkillSlugResolutionService.Preference.PUBLISHED))
|
||||
.thenReturn(privateSkill);
|
||||
when(visibilityChecker.canAccess(privateSkill, null, Map.of())).thenReturn(false);
|
||||
|
||||
assertThatThrownBy(() -> service.resolveVisible("team-a", "priv", null, Map.of()))
|
||||
.isInstanceOf(DomainNotFoundException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveVisible_returnsSkillWhenCallerHasNamespaceAccess() {
|
||||
Namespace namespace = new Namespace("team-a", "Team A", "owner-1");
|
||||
ReflectionTestUtils.setField(namespace, "id", 1L);
|
||||
Skill privateSkill = new Skill(1L, "priv", "owner-1", SkillVisibility.PRIVATE);
|
||||
ReflectionTestUtils.setField(privateSkill, "id", 7L);
|
||||
privateSkill.setLatestVersionId(70L);
|
||||
|
||||
when(namespaceRepository.findBySlug("team-a")).thenReturn(Optional.of(namespace));
|
||||
when(skillSlugResolutionService.resolve(1L, "priv", "admin-1", SkillSlugResolutionService.Preference.PUBLISHED))
|
||||
.thenReturn(privateSkill);
|
||||
when(visibilityChecker.canAccess(privateSkill, "admin-1", Map.of(1L, NamespaceRole.ADMIN))).thenReturn(true);
|
||||
|
||||
CompatSkillLookupService.CompatSkillContext result = service.resolveVisible(
|
||||
"team-a",
|
||||
"priv",
|
||||
"admin-1",
|
||||
Map.of(1L, NamespaceRole.ADMIN)
|
||||
);
|
||||
|
||||
assertThat(result.skill().getId()).isEqualTo(7L);
|
||||
}
|
||||
}
|
||||
|
|
@ -89,18 +89,9 @@ class NamespacePortalControllerTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
void getNamespace_hidesArchivedNamespaceFromAnonymousUsers() throws Exception {
|
||||
Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ARCHIVED, NamespaceType.TEAM);
|
||||
given(namespaceService.getNamespaceBySlugForRead("team-a", null, Map.of())).willThrow(
|
||||
new com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException(
|
||||
"error.namespace.slug.notFound",
|
||||
"team-a"
|
||||
)
|
||||
);
|
||||
|
||||
void getNamespace_requiresAuthentication() throws Exception {
|
||||
mockMvc.perform(get("/api/v1/namespaces/team-a"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value(400));
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -35,13 +35,14 @@ class PrometheusEndpointTest {
|
|||
private DeviceAuthService deviceAuthService;
|
||||
|
||||
@Test
|
||||
void prometheusEndpoint_exposesCustomMetrics() {
|
||||
void metricsRegistry_stillRecordsCustomMetrics_whenPrometheusEndpointIsDisabled() {
|
||||
skillHubMetrics.incrementUserRegister();
|
||||
skillHubMetrics.recordLocalLogin(true);
|
||||
skillHubMetrics.incrementSkillPublish("global", "PENDING_REVIEW");
|
||||
|
||||
assertThat(environment.getProperty("management.endpoints.web.exposure.include"))
|
||||
.contains("prometheus");
|
||||
.doesNotContain("prometheus")
|
||||
.doesNotContain("metrics");
|
||||
assertThat(meterRegistry.get("skillhub.user.register").counter().count()).isEqualTo(1.0d);
|
||||
assertThat(meterRegistry.get("skillhub.auth.login")
|
||||
.tag("method", "local")
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.iflytek.skillhub.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
|
@ -13,7 +14,9 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
|||
import com.iflytek.skillhub.domain.namespace.NamespaceService;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceType;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.List;
|
||||
|
|
@ -60,6 +63,39 @@ class NamespacePortalQueryAppServiceTest {
|
|||
assertThat(response.get(1).currentUserRole()).isEqualTo(NamespaceRole.ADMIN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void listNamespaces_returnsOnlyCurrentUsersActiveNamespaces() {
|
||||
Namespace teamA = namespace(1L, "team-a");
|
||||
Namespace teamB = namespace(2L, "team-b");
|
||||
Namespace archived = namespace(3L, "archived");
|
||||
archived.setStatus(NamespaceStatus.ARCHIVED);
|
||||
|
||||
when(namespaceRepository.findByIdIn(anyList())).thenReturn(List.of(teamB, archived, teamA));
|
||||
|
||||
var response = service.listNamespaces(
|
||||
PageRequest.of(0, 10),
|
||||
Map.of(
|
||||
1L, NamespaceRole.MEMBER,
|
||||
2L, NamespaceRole.ADMIN,
|
||||
3L, NamespaceRole.OWNER
|
||||
)
|
||||
);
|
||||
|
||||
assertThat(response.items()).hasSize(2);
|
||||
assertThat(response.items().get(0).slug()).isEqualTo("team-a");
|
||||
assertThat(response.items().get(1).slug()).isEqualTo("team-b");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getNamespace_throwsWhenCurrentUserIsNotNamespaceMember() {
|
||||
Namespace namespace = namespace(1L, "team-a");
|
||||
when(namespaceService.getNamespaceBySlugForRead("team-a", "user-1", Map.of()))
|
||||
.thenReturn(namespace);
|
||||
|
||||
assertThatThrownBy(() -> service.getNamespace("team-a", "user-1", Map.of()))
|
||||
.isInstanceOf(DomainForbiddenException.class);
|
||||
}
|
||||
|
||||
private Namespace namespace(Long id, String slug) {
|
||||
Namespace namespace = new Namespace(slug, slug, "owner-1");
|
||||
ReflectionTestUtils.setField(namespace, "id", id);
|
||||
|
|
|
|||
|
|
@ -71,10 +71,10 @@ public class RouteSecurityPolicyRegistry {
|
|||
RouteAuthorizationPolicy.roles(HttpMethod.DELETE, "/api/v1/skills/*/*", "SUPER_ADMIN"),
|
||||
RouteAuthorizationPolicy.authenticated(HttpMethod.DELETE, "/api/web/skills/id/*"),
|
||||
RouteAuthorizationPolicy.authenticated(HttpMethod.DELETE, "/api/web/skills/*/*"),
|
||||
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/namespaces"),
|
||||
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/namespaces/*"),
|
||||
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/namespaces"),
|
||||
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/namespaces/*"),
|
||||
RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/v1/namespaces"),
|
||||
RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/v1/namespaces/*"),
|
||||
RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/web/namespaces"),
|
||||
RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/web/namespaces/*"),
|
||||
RouteAuthorizationPolicy.authenticated(null, "/api/v1/admin/**")
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -58,6 +58,21 @@ class RouteSecurityPolicyRegistryTest {
|
|||
assertTrue(matchedWeb);
|
||||
}
|
||||
|
||||
@Test
|
||||
void authorizationPolicies_shouldRequireAuthenticationForNamespaceDiscovery() {
|
||||
boolean matchedV1 = registry.authorizationPolicies().stream()
|
||||
.anyMatch(policy -> policy.method() == HttpMethod.GET
|
||||
&& "/api/v1/namespaces".equals(policy.pattern())
|
||||
&& policy.accessLevel() == RouteSecurityPolicyRegistry.AccessLevel.AUTHENTICATED);
|
||||
boolean matchedWeb = registry.authorizationPolicies().stream()
|
||||
.anyMatch(policy -> policy.method() == HttpMethod.GET
|
||||
&& "/api/web/namespaces".equals(policy.pattern())
|
||||
&& policy.accessLevel() == RouteSecurityPolicyRegistry.AccessLevel.AUTHENTICATED);
|
||||
|
||||
assertTrue(matchedV1);
|
||||
assertTrue(matchedWeb);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldIgnoreCsrf_forBearerAndApiPaths() {
|
||||
assertTrue(registry.shouldIgnoreCsrf("/api/v1/admin/users", null));
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue