fix(security): close unauthorized metrics and compat access paths

This commit is contained in:
dongmucat 2026-04-07 16:24:15 +08:00
parent c2981836bb
commit 441dc9e0e7
16 changed files with 425 additions and 41 deletions

View file

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

View file

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

View file

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

View file

@ -83,7 +83,8 @@ public class ClawHubRegistryFacade {
CompatSkillLookupService.CompatSkillContext context = compatSkillLookupService.resolveVisible(
coordinate.namespace(),
coordinate.slug(),
userId
userId,
normalizeRoles(userNsRoles)
);
Skill skill = context.skill();

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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/**")
);

View file

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