mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-27 11:14:59 +00:00
Merge pull request #765 from iflytek/fix/pr735-subscription-authorization-20260827
fix(subscription): enforce current visibility for subscribers
This commit is contained in:
commit
2e78f79e83
16 changed files with 908 additions and 8 deletions
|
|
@ -8,6 +8,7 @@ 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.social.SkillSubscriptionService;
|
||||
import com.iflytek.skillhub.domain.social.SubscriptionRecipientEligibility;
|
||||
import com.iflytek.skillhub.notification.domain.NotificationCategory;
|
||||
import com.iflytek.skillhub.notification.service.NotificationDispatcher;
|
||||
import org.slf4j.Logger;
|
||||
|
|
@ -33,6 +34,7 @@ public class NotificationEventListener {
|
|||
private final NotificationDispatcher dispatcher;
|
||||
private final SkillSubscriptionService skillSubscriptionService;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final SubscriptionRecipientEligibility subscriptionEligibility;
|
||||
|
||||
public NotificationEventListener(SkillRepository skillRepository,
|
||||
SkillVersionRepository skillVersionRepository,
|
||||
|
|
@ -40,7 +42,8 @@ public class NotificationEventListener {
|
|||
RecipientResolver recipientResolver,
|
||||
NotificationDispatcher dispatcher,
|
||||
SkillSubscriptionService skillSubscriptionService,
|
||||
ObjectMapper objectMapper) {
|
||||
ObjectMapper objectMapper,
|
||||
SubscriptionRecipientEligibility subscriptionEligibility) {
|
||||
this.skillRepository = skillRepository;
|
||||
this.skillVersionRepository = skillVersionRepository;
|
||||
this.namespaceRepository = namespaceRepository;
|
||||
|
|
@ -48,6 +51,7 @@ public class NotificationEventListener {
|
|||
this.dispatcher = dispatcher;
|
||||
this.skillSubscriptionService = skillSubscriptionService;
|
||||
this.objectMapper = objectMapper;
|
||||
this.subscriptionEligibility = subscriptionEligibility;
|
||||
}
|
||||
|
||||
@Async("skillhubEventExecutor")
|
||||
|
|
@ -74,6 +78,8 @@ public class NotificationEventListener {
|
|||
if (subscribers.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
var namespace = namespaceRepository.findById(skill.getNamespaceId()).orElse(null);
|
||||
subscribers = subscriptionEligibility.currentRecipients(skill, namespace, subscribers);
|
||||
String title = "Skill updated: " + skillDisplayName(skill);
|
||||
Map<String, Object> body = bodyWithSkill(skill);
|
||||
versionLabel(event.versionId(), body);
|
||||
|
|
@ -96,6 +102,8 @@ public class NotificationEventListener {
|
|||
if (subscribers.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
var namespace = namespaceRepository.findById(skill.getNamespaceId()).orElse(null);
|
||||
subscribers = subscriptionEligibility.yankedRecipients(skill, namespace, subscribers, event.wasPublished());
|
||||
String title = "Skill version yanked: " + skillDisplayName(skill);
|
||||
Map<String, Object> body = bodyWithSkill(skill);
|
||||
versionLabel(event.versionId(), body);
|
||||
|
|
|
|||
|
|
@ -185,3 +185,4 @@ promotion.status.invalid=Unsupported promotion status: {0}
|
|||
promotion.sort.field.invalid=Unsupported promotion sort field: {0}
|
||||
promotion.sort.direction.invalid=Unsupported promotion sort direction: {0}
|
||||
promotion.sort.pending_unsupported=Pending promotion requests do not support reviewed-time sorting
|
||||
error.skill.subscription.noPermission=You do not have permission to subscribe to this skill.
|
||||
|
|
|
|||
|
|
@ -185,3 +185,4 @@ promotion.status.invalid=不支持的提升审核状态:{0}
|
|||
promotion.sort.field.invalid=不支持的提升审核排序字段:{0}
|
||||
promotion.sort.direction.invalid=不支持的提升审核排序方向:{0}
|
||||
promotion.sort.pending_unsupported=待审核提升请求不支持按处理时间排序
|
||||
error.skill.subscription.noPermission=您没有订阅此技能的权限。
|
||||
|
|
|
|||
|
|
@ -2,6 +2,17 @@ package com.iflytek.skillhub.domain.social;
|
|||
|
||||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.skill.SkillRepository;
|
||||
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.NamespaceRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
||||
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.domain.shared.exception.DomainForbiddenException;
|
||||
import com.iflytek.skillhub.domain.social.event.SkillSubscribedEvent;
|
||||
import com.iflytek.skillhub.domain.social.event.SkillUnsubscribedEvent;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
|
|
@ -13,10 +24,13 @@ import org.mockito.junit.jupiter.MockitoExtension;
|
|||
import org.springframework.context.ApplicationEventPublisher;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class SkillSubscriptionServiceTest {
|
||||
|
|
@ -24,17 +38,34 @@ class SkillSubscriptionServiceTest {
|
|||
@Mock private SkillSubscriptionRepository subscriptionRepository;
|
||||
@Mock private SkillRepository skillRepository;
|
||||
@Mock private ApplicationEventPublisher eventPublisher;
|
||||
@Mock private NamespaceRepository namespaceRepository;
|
||||
@Mock private NamespaceMemberRepository namespaceMemberRepository;
|
||||
@Mock private UserAccountRepository userAccountRepository;
|
||||
|
||||
private SkillSubscriptionService service;
|
||||
|
||||
private void allowPublicSubscription(Skill skill) {
|
||||
skill.setLatestVersionId(10L);
|
||||
when(userAccountRepository.findById("user-1"))
|
||||
.thenReturn(Optional.of(new UserAccount("user-1", "User", null, null)));
|
||||
Namespace namespace = new Namespace("demo", "Demo", "owner");
|
||||
when(namespaceRepository.findById(skill.getNamespaceId())).thenReturn(Optional.of(namespace));
|
||||
when(namespaceMemberRepository.findByNamespaceIdAndUserId(skill.getNamespaceId(), "user-1"))
|
||||
.thenReturn(Optional.empty());
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new SkillSubscriptionService(subscriptionRepository, skillRepository, eventPublisher);
|
||||
service = new SkillSubscriptionService(subscriptionRepository, skillRepository, eventPublisher,
|
||||
namespaceRepository, namespaceMemberRepository, userAccountRepository,
|
||||
new SubscriptionMetadataAccessPolicy());
|
||||
}
|
||||
|
||||
@Test
|
||||
void subscribe_createsSubscriptionAndPublishesEvent() {
|
||||
when(skillRepository.findById(1L)).thenReturn(Optional.of(mock(Skill.class)));
|
||||
Skill skill = new Skill(5L, "public-skill", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC);
|
||||
allowPublicSubscription(skill);
|
||||
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill));
|
||||
when(subscriptionRepository.findBySkillIdAndUserId(1L, "user-1")).thenReturn(Optional.empty());
|
||||
when(subscriptionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
|
|
@ -50,7 +81,9 @@ class SkillSubscriptionServiceTest {
|
|||
|
||||
@Test
|
||||
void subscribe_idempotent_doesNotDuplicate() {
|
||||
when(skillRepository.findById(1L)).thenReturn(Optional.of(mock(Skill.class)));
|
||||
Skill skill = new Skill(5L, "public-skill", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC);
|
||||
allowPublicSubscription(skill);
|
||||
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill));
|
||||
when(subscriptionRepository.findBySkillIdAndUserId(1L, "user-1"))
|
||||
.thenReturn(Optional.of(mock(SkillSubscription.class)));
|
||||
|
||||
|
|
@ -102,4 +135,120 @@ class SkillSubscriptionServiceTest {
|
|||
|
||||
assertThat(service.isSubscribed(1L, "user-1")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void subscribe_rejectsInactiveAccountWithoutMutation() {
|
||||
Skill skill = new Skill(5L, "private-skill", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PRIVATE);
|
||||
skill.setLatestVersionId(10L);
|
||||
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill));
|
||||
UserAccount account = new UserAccount("user-1", "User", null, null);
|
||||
account.setStatus(UserStatus.DISABLED);
|
||||
when(userAccountRepository.findById("user-1")).thenReturn(Optional.of(account));
|
||||
|
||||
org.assertj.core.api.Assertions.assertThatThrownBy(() -> service.subscribe(1L, "user-1"))
|
||||
.isInstanceOf(DomainForbiddenException.class);
|
||||
|
||||
verifyNoInteractions(subscriptionRepository);
|
||||
verify(skillRepository, never()).incrementSubscriptionCount(anyLong());
|
||||
verifyNoInteractions(eventPublisher);
|
||||
}
|
||||
|
||||
@Test
|
||||
void subscribe_rejectsRemovedMemberOfArchivedNamespaceWithoutMutation() {
|
||||
Skill skill = new Skill(5L, "public-skill", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC);
|
||||
skill.setLatestVersionId(10L);
|
||||
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill));
|
||||
when(userAccountRepository.findById("user-1"))
|
||||
.thenReturn(Optional.of(new UserAccount("user-1", "User", null, null)));
|
||||
Namespace namespace = new Namespace("archived", "Archived", "owner");
|
||||
namespace.setStatus(NamespaceStatus.ARCHIVED);
|
||||
when(namespaceRepository.findById(5L)).thenReturn(Optional.of(namespace));
|
||||
when(namespaceMemberRepository.findByNamespaceIdAndUserId(5L, "user-1")).thenReturn(Optional.empty());
|
||||
|
||||
org.assertj.core.api.Assertions.assertThatThrownBy(() -> service.subscribe(1L, "user-1"))
|
||||
.isInstanceOf(DomainForbiddenException.class);
|
||||
|
||||
verifyNoInteractions(subscriptionRepository);
|
||||
verify(skillRepository, never()).incrementSubscriptionCount(anyLong());
|
||||
verifyNoInteractions(eventPublisher);
|
||||
}
|
||||
|
||||
static Stream<DeniedSubscription> deniedSubscriptions() {
|
||||
return Stream.of(
|
||||
new DeniedSubscription("private member", SkillVisibility.PRIVATE, false, NamespaceStatus.ACTIVE,
|
||||
NamespaceRole.MEMBER),
|
||||
new DeniedSubscription("private nonmember", SkillVisibility.PRIVATE, false, NamespaceStatus.ACTIVE,
|
||||
null),
|
||||
new DeniedSubscription("private cross namespace member", SkillVisibility.PRIVATE, false,
|
||||
NamespaceStatus.ACTIVE, null),
|
||||
new DeniedSubscription("hidden public", SkillVisibility.PUBLIC, true, NamespaceStatus.ACTIVE,
|
||||
NamespaceRole.MEMBER)
|
||||
);
|
||||
}
|
||||
|
||||
@ParameterizedTest(name = "{0}")
|
||||
@MethodSource("deniedSubscriptions")
|
||||
void subscribe_rejectsUnauthorizedMetadataWithoutAnyMutation(DeniedSubscription scenario) {
|
||||
Skill skill = new Skill(5L, "restricted", "owner", scenario.visibility());
|
||||
skill.setLatestVersionId(10L);
|
||||
skill.setHidden(scenario.hidden());
|
||||
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill));
|
||||
when(userAccountRepository.findById("user-1"))
|
||||
.thenReturn(Optional.of(new UserAccount("user-1", "User", null, null)));
|
||||
Namespace namespace = new Namespace("team", "Team", "owner");
|
||||
namespace.setStatus(scenario.namespaceStatus());
|
||||
when(namespaceRepository.findById(5L)).thenReturn(Optional.of(namespace));
|
||||
if (scenario.role() == null) {
|
||||
when(namespaceMemberRepository.findByNamespaceIdAndUserId(5L, "user-1"))
|
||||
.thenReturn(Optional.empty());
|
||||
} else {
|
||||
when(namespaceMemberRepository.findByNamespaceIdAndUserId(5L, "user-1"))
|
||||
.thenReturn(Optional.of(new NamespaceMember(5L, "user-1", scenario.role())));
|
||||
}
|
||||
|
||||
org.assertj.core.api.Assertions.assertThatThrownBy(() -> service.subscribe(1L, "user-1"))
|
||||
.isInstanceOf(DomainForbiddenException.class);
|
||||
|
||||
verifyNoInteractions(subscriptionRepository);
|
||||
verify(skillRepository, never()).incrementSubscriptionCount(anyLong());
|
||||
verifyNoInteractions(eventPublisher);
|
||||
}
|
||||
|
||||
@Test
|
||||
void subscribe_allowsPublicArchivedSkillBecauseMetadataPurposeDoesNotRequireActiveSkill() {
|
||||
Skill skill = new Skill(5L, "archived-skill", "owner", SkillVisibility.PUBLIC);
|
||||
skill.setStatus(com.iflytek.skillhub.domain.skill.SkillStatus.ARCHIVED);
|
||||
skill.setLatestVersionId(10L);
|
||||
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill));
|
||||
when(userAccountRepository.findById("user-1"))
|
||||
.thenReturn(Optional.of(new UserAccount("user-1", "User", null, null)));
|
||||
when(namespaceRepository.findById(5L)).thenReturn(Optional.of(new Namespace("team", "Team", "owner")));
|
||||
when(namespaceMemberRepository.findByNamespaceIdAndUserId(5L, "user-1")).thenReturn(Optional.empty());
|
||||
when(subscriptionRepository.findBySkillIdAndUserId(1L, "user-1")).thenReturn(Optional.empty());
|
||||
|
||||
service.subscribe(1L, "user-1");
|
||||
|
||||
verify(subscriptionRepository).save(any(SkillSubscription.class));
|
||||
verify(skillRepository).incrementSubscriptionCount(1L);
|
||||
verify(eventPublisher).publishEvent(any(SkillSubscribedEvent.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAndDeleteDoNotConsultMetadataAuthorizationDependencies() {
|
||||
when(skillRepository.findById(1L)).thenReturn(Optional.of(mock(Skill.class)));
|
||||
SkillSubscription existing = mock(SkillSubscription.class);
|
||||
when(subscriptionRepository.findBySkillIdAndUserId(1L, "user-1"))
|
||||
.thenReturn(Optional.of(existing));
|
||||
|
||||
assertThat(service.isSubscribed(1L, "user-1")).isTrue();
|
||||
service.unsubscribe(1L, "user-1");
|
||||
|
||||
verifyNoInteractions(namespaceRepository, namespaceMemberRepository, userAccountRepository);
|
||||
verify(subscriptionRepository).delete(existing);
|
||||
}
|
||||
|
||||
record DeniedSubscription(String label, SkillVisibility visibility, boolean hidden,
|
||||
NamespaceStatus namespaceStatus, NamespaceRole role) {
|
||||
@Override public String toString() { return label; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,10 +4,20 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
|||
import com.iflytek.skillhub.domain.event.*;
|
||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMember;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
|
||||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.skill.SkillRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
|
||||
import com.iflytek.skillhub.domain.social.SkillSubscriptionService;
|
||||
import com.iflytek.skillhub.domain.social.SubscriptionRecipientEligibility;
|
||||
import com.iflytek.skillhub.domain.social.SubscriptionMetadataAccessPolicy;
|
||||
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.notification.domain.NotificationCategory;
|
||||
import com.iflytek.skillhub.notification.service.NotificationDispatcher;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
|
@ -31,10 +41,21 @@ class NotificationEventListenerTest {
|
|||
@Mock RecipientResolver recipientResolver;
|
||||
@Mock NotificationDispatcher dispatcher;
|
||||
@Mock ObjectMapper objectMapper;
|
||||
@Mock SkillSubscriptionService skillSubscriptionService;
|
||||
@Mock UserAccountRepository userAccountRepository;
|
||||
@Mock NamespaceMemberRepository namespaceMemberRepository;
|
||||
|
||||
@InjectMocks
|
||||
NotificationEventListener listener;
|
||||
|
||||
@org.junit.jupiter.api.BeforeEach
|
||||
void setUpListener() {
|
||||
listener = new NotificationEventListener(skillRepository, skillVersionRepository, namespaceRepository,
|
||||
recipientResolver, dispatcher, skillSubscriptionService, objectMapper,
|
||||
new SubscriptionRecipientEligibility(userAccountRepository, namespaceMemberRepository,
|
||||
new SubscriptionMetadataAccessPolicy()));
|
||||
}
|
||||
|
||||
private Skill mockSkill(Long id) {
|
||||
Skill skill = mock(Skill.class);
|
||||
when(skill.getId()).thenReturn(id);
|
||||
|
|
@ -225,4 +246,153 @@ class NotificationEventListenerTest {
|
|||
verify(dispatcher).dispatch(eq("reporter-1"), eq(NotificationCategory.REPORT),
|
||||
eq("REPORT_RESOLVED"), anyString(), anyString(), eq("SKILL"), eq(1L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishSubscriberFanoutExcludesInactiveAccount() {
|
||||
Skill skill = skill(1L, "owner", "owner");
|
||||
skill.setLatestVersionId(10L);
|
||||
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill));
|
||||
when(skillSubscriptionService.findSubscribersBySkillId(1L)).thenReturn(List.of("inactive"));
|
||||
UserAccount inactive = new UserAccount("inactive", "Inactive", null, null);
|
||||
inactive.setStatus(UserStatus.DISABLED);
|
||||
when(userAccountRepository.findByIdIn(List.of("inactive"))).thenReturn(List.of(inactive));
|
||||
mockNamespace();
|
||||
|
||||
listener.onSkillPublishedForSubscribers(new SkillPublishedEvent(1L, 10L, "owner"));
|
||||
|
||||
verifyNoInteractions(dispatcher);
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishSubscriberFanoutFailsClosedBeforeDispatchWhenAccountBatchFails() {
|
||||
Skill skill = skill(1L, "owner", "owner");
|
||||
skill.setLatestVersionId(10L);
|
||||
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill));
|
||||
when(skillSubscriptionService.findSubscribersBySkillId(1L)).thenReturn(List.of("user-1", "user-2"));
|
||||
when(userAccountRepository.findByIdIn(anyList())).thenThrow(new IllegalStateException("account batch unavailable"));
|
||||
|
||||
org.assertj.core.api.Assertions.assertThatThrownBy(() ->
|
||||
listener.onSkillPublishedForSubscribers(new SkillPublishedEvent(1L, 10L, "owner")))
|
||||
.isInstanceOf(IllegalStateException.class);
|
||||
|
||||
verifyNoInteractions(dispatcher);
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishSubscriberFanoutDispatchesOnlyEligibleNonPublisherWithExactPayload() throws Exception {
|
||||
Skill skill = skill(1L, "publisher", "publisher");
|
||||
skill.setLatestVersionId(10L);
|
||||
skill.setVisibility(SkillVisibility.PRIVATE);
|
||||
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill));
|
||||
when(skillSubscriptionService.findSubscribersBySkillId(1L))
|
||||
.thenReturn(List.of("publisher", "admin", "member", "inactive", "missing"));
|
||||
UserAccount publisher = new UserAccount("publisher", "Publisher", null, null);
|
||||
UserAccount admin = new UserAccount("admin", "Admin", null, null);
|
||||
UserAccount member = new UserAccount("member", "Member", null, null);
|
||||
UserAccount inactive = new UserAccount("inactive", "Inactive", null, null);
|
||||
inactive.setStatus(UserStatus.DISABLED);
|
||||
when(userAccountRepository.findByIdIn(anyList())).thenReturn(List.of(publisher, admin, member, inactive));
|
||||
when(namespaceMemberRepository.findByNamespaceIdAndUserIdIn(eq(5L), anyCollection()))
|
||||
.thenReturn(List.of(new NamespaceMember(5L, "admin", NamespaceRole.ADMIN),
|
||||
new NamespaceMember(5L, "member", NamespaceRole.MEMBER)));
|
||||
mockNamespace();
|
||||
when(objectMapper.writeValueAsString(any())).thenReturn("{\"skillId\":1,\"version\":\"1.0.0\"}");
|
||||
|
||||
listener.onSkillPublishedForSubscribers(new SkillPublishedEvent(1L, 10L, "publisher"));
|
||||
|
||||
verify(dispatcher).dispatch("admin", NotificationCategory.PUBLISH, "SUBSCRIPTION_NEW_VERSION",
|
||||
"Skill updated: Test Skill", "{\"skillId\":1,\"version\":\"1.0.0\"}", "SKILL", 1L);
|
||||
verifyNoMoreInteractions(dispatcher);
|
||||
}
|
||||
|
||||
@Test
|
||||
void yankWithoutFallbackUsesVerifiedPreYankPublicationAndExcludesActor() throws Exception {
|
||||
Skill skill = skill(1L, "owner", "owner");
|
||||
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill));
|
||||
when(skillSubscriptionService.findSubscribersBySkillId(1L)).thenReturn(List.of("actor", "subscriber"));
|
||||
when(userAccountRepository.findByIdIn(anyList())).thenReturn(List.of(
|
||||
new UserAccount("actor", "Actor", null, null),
|
||||
new UserAccount("subscriber", "Subscriber", null, null)));
|
||||
mockNamespace();
|
||||
when(objectMapper.writeValueAsString(any())).thenReturn("{\"skillId\":1,\"versionId\":10}");
|
||||
|
||||
listener.onSkillVersionYankedForSubscribers(new SkillVersionYankedEvent(1L, 10L, "actor", true));
|
||||
|
||||
verify(dispatcher).dispatch("subscriber", NotificationCategory.PUBLISH, "SUBSCRIPTION_VERSION_YANKED",
|
||||
"Skill version yanked: Test Skill", "{\"skillId\":1,\"versionId\":10}", "SKILL", 1L);
|
||||
verifyNoMoreInteractions(dispatcher);
|
||||
}
|
||||
|
||||
@Test
|
||||
void yankDoesNotDispatchWhenEventDoesNotVerifyPublishedPreState() {
|
||||
Skill skill = skill(1L, "owner", "owner");
|
||||
skill.setLatestVersionId(9L);
|
||||
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill));
|
||||
when(skillSubscriptionService.findSubscribersBySkillId(1L)).thenReturn(List.of("subscriber"));
|
||||
when(userAccountRepository.findByIdIn(anyList())).thenReturn(List.of(
|
||||
new UserAccount("subscriber", "Subscriber", null, null)));
|
||||
mockNamespace();
|
||||
|
||||
listener.onSkillVersionYankedForSubscribers(new SkillVersionYankedEvent(1L, 10L, "actor", false));
|
||||
|
||||
verifyNoInteractions(dispatcher);
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishFanoutFailsClosedBeforeDispatchWhenNamespaceReadFails() {
|
||||
Skill skill = skill(1L, "owner", "owner");
|
||||
skill.setLatestVersionId(10L);
|
||||
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill));
|
||||
when(skillSubscriptionService.findSubscribersBySkillId(1L)).thenReturn(List.of("user-1"));
|
||||
when(namespaceRepository.findById(5L)).thenThrow(new IllegalStateException("namespace unavailable"));
|
||||
|
||||
org.assertj.core.api.Assertions.assertThatThrownBy(() ->
|
||||
listener.onSkillPublishedForSubscribers(new SkillPublishedEvent(1L, 10L, "owner")))
|
||||
.isInstanceOf(IllegalStateException.class);
|
||||
|
||||
verifyNoInteractions(dispatcher);
|
||||
}
|
||||
|
||||
@Test
|
||||
void yankFanoutFailsClosedBeforeDispatchWhenMembershipBatchFails() {
|
||||
Skill skill = skill(1L, "owner", "owner");
|
||||
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill));
|
||||
when(skillSubscriptionService.findSubscribersBySkillId(1L)).thenReturn(List.of("user-1", "user-2"));
|
||||
when(userAccountRepository.findByIdIn(anyList())).thenReturn(List.of(
|
||||
new UserAccount("user-1", "One", null, null),
|
||||
new UserAccount("user-2", "Two", null, null)));
|
||||
when(namespaceRepository.findById(5L))
|
||||
.thenReturn(Optional.of(new Namespace("demo", "Demo", "owner")));
|
||||
when(namespaceMemberRepository.findByNamespaceIdAndUserIdIn(eq(5L), anyCollection()))
|
||||
.thenThrow(new IllegalStateException("membership unavailable"));
|
||||
|
||||
org.assertj.core.api.Assertions.assertThatThrownBy(() ->
|
||||
listener.onSkillVersionYankedForSubscribers(new SkillVersionYankedEvent(1L, 10L, "actor", true)))
|
||||
.isInstanceOf(IllegalStateException.class);
|
||||
|
||||
verifyNoInteractions(dispatcher);
|
||||
}
|
||||
|
||||
@Test
|
||||
void archivedNamespaceRemovedSubscriberIsRejectedButCurrentMemberReceivesYank() throws Exception {
|
||||
Skill skill = skill(1L, "owner", "owner");
|
||||
skill.setStatus(com.iflytek.skillhub.domain.skill.SkillStatus.ARCHIVED);
|
||||
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill));
|
||||
when(skillSubscriptionService.findSubscribersBySkillId(1L)).thenReturn(List.of("current", "removed"));
|
||||
when(userAccountRepository.findByIdIn(anyList())).thenReturn(List.of(
|
||||
new UserAccount("current", "Current", null, null),
|
||||
new UserAccount("removed", "Removed", null, null)));
|
||||
Namespace namespace = new Namespace("archived", "Archived", "owner");
|
||||
namespace.setStatus(NamespaceStatus.ARCHIVED);
|
||||
when(namespaceRepository.findById(5L)).thenReturn(Optional.of(namespace));
|
||||
when(namespaceMemberRepository.findByNamespaceIdAndUserIdIn(eq(5L), anyCollection()))
|
||||
.thenReturn(List.of(new NamespaceMember(5L, "current", NamespaceRole.MEMBER)));
|
||||
when(objectMapper.writeValueAsString(any())).thenReturn("{}");
|
||||
|
||||
listener.onSkillVersionYankedForSubscribers(new SkillVersionYankedEvent(1L, 10L, "actor", true));
|
||||
|
||||
verify(dispatcher).dispatch(eq("current"), eq(NotificationCategory.PUBLISH),
|
||||
eq("SUBSCRIPTION_VERSION_YANKED"), anyString(), eq("{}"), eq("SKILL"), eq(1L));
|
||||
verifyNoMoreInteractions(dispatcher);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,293 @@
|
|||
package com.iflytek.skillhub.listener;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.iflytek.skillhub.domain.event.SkillPublishedEvent;
|
||||
import com.iflytek.skillhub.domain.event.SkillVersionYankedEvent;
|
||||
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.NamespaceRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
|
||||
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.social.SkillSubscriptionService;
|
||||
import com.iflytek.skillhub.domain.social.SubscriptionMetadataAccessPolicy;
|
||||
import com.iflytek.skillhub.domain.social.SubscriptionRecipientEligibility;
|
||||
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.notification.domain.Notification;
|
||||
import com.iflytek.skillhub.notification.domain.NotificationCategory;
|
||||
import com.iflytek.skillhub.notification.domain.NotificationChannel;
|
||||
import com.iflytek.skillhub.notification.service.NotificationDispatcher;
|
||||
import com.iflytek.skillhub.notification.service.NotificationPreferenceService;
|
||||
import com.iflytek.skillhub.notification.service.NotificationService;
|
||||
import com.iflytek.skillhub.notification.sse.SseEmitterManager;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.EnumSource;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyCollection;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class SubscriberNotificationSinkTest {
|
||||
|
||||
private static final Long SKILL_ID = 1L;
|
||||
private static final Long NAMESPACE_ID = 5L;
|
||||
private static final Long VERSION_ID = 10L;
|
||||
private static final Instant CREATED_AT = Instant.parse("2026-08-19T20:30:00Z");
|
||||
|
||||
@Mock SkillRepository skillRepository;
|
||||
@Mock SkillVersionRepository skillVersionRepository;
|
||||
@Mock NamespaceRepository namespaceRepository;
|
||||
@Mock RecipientResolver recipientResolver;
|
||||
@Mock SkillSubscriptionService subscriptionService;
|
||||
@Mock UserAccountRepository accountRepository;
|
||||
@Mock NamespaceMemberRepository memberRepository;
|
||||
@Mock NotificationService notificationService;
|
||||
@Mock NotificationPreferenceService preferenceService;
|
||||
@Mock SseEmitterManager sseEmitterManager;
|
||||
|
||||
private NotificationEventListener listener;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
SubscriptionRecipientEligibility eligibility = new SubscriptionRecipientEligibility(
|
||||
accountRepository, memberRepository, new SubscriptionMetadataAccessPolicy());
|
||||
NotificationDispatcher dispatcher = new NotificationDispatcher(
|
||||
notificationService, preferenceService, sseEmitterManager);
|
||||
listener = new NotificationEventListener(skillRepository, skillVersionRepository, namespaceRepository,
|
||||
recipientResolver, dispatcher, subscriptionService, new ObjectMapper(), eligibility);
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishPersistsAndPushesOnlyCurrentEligibleNonPublisherAcrossAuthorizationMatrix() {
|
||||
Skill skill = skill(SkillVisibility.PRIVATE, false, VERSION_ID);
|
||||
Namespace namespace = namespace(NamespaceStatus.ACTIVE);
|
||||
List<String> candidates = List.of("publisher", "current-admin", "stale-removed", "inactive",
|
||||
"missing", "private-member", "cross-namespace", "platform-super-admin");
|
||||
arrangeEvent(skill, namespace, candidates);
|
||||
when(accountRepository.findByIdIn(candidates)).thenReturn(List.of(
|
||||
account("publisher"), account("current-admin"), account("stale-removed"),
|
||||
inactiveAccount("inactive"), account("private-member"), account("cross-namespace"),
|
||||
account("platform-super-admin")));
|
||||
when(memberRepository.findByNamespaceIdAndUserIdIn(NAMESPACE_ID, candidates)).thenReturn(List.of(
|
||||
member("current-admin", NamespaceRole.ADMIN),
|
||||
member("private-member", NamespaceRole.MEMBER)));
|
||||
enablePersistenceFor("current-admin", "SUBSCRIPTION_NEW_VERSION");
|
||||
|
||||
listener.onSkillPublishedForSubscribers(new SkillPublishedEvent(SKILL_ID, VERSION_ID, "publisher"));
|
||||
|
||||
String body = "{\"skillId\":1,\"skillName\":\"Test Skill\",\"slug\":\"test-skill\",\"namespace\":\"demo\"}";
|
||||
verify(notificationService).create("current-admin", NotificationCategory.PUBLISH,
|
||||
"SUBSCRIPTION_NEW_VERSION", "Skill updated: Test Skill", body, "SKILL", SKILL_ID);
|
||||
assertSingleSse("current-admin", "SUBSCRIPTION_NEW_VERSION", body);
|
||||
verify(accountRepository).findByIdIn(candidates);
|
||||
verify(memberRepository).findByNamespaceIdAndUserIdIn(NAMESPACE_ID, candidates);
|
||||
}
|
||||
|
||||
@Test
|
||||
void hiddenPublishPersistsAndPushesOnlyManagerWhileOrdinaryAndPlatformOnlyCandidatesStayAtZero() {
|
||||
Skill skill = skill(SkillVisibility.PUBLIC, true, VERSION_ID);
|
||||
Namespace namespace = namespace(NamespaceStatus.ACTIVE);
|
||||
List<String> candidates = List.of("manager", "ordinary-member", "platform-super-admin");
|
||||
arrangeEvent(skill, namespace, candidates);
|
||||
when(accountRepository.findByIdIn(candidates)).thenReturn(List.of(
|
||||
account("manager"), account("ordinary-member"), account("platform-super-admin")));
|
||||
when(memberRepository.findByNamespaceIdAndUserIdIn(NAMESPACE_ID, candidates)).thenReturn(List.of(
|
||||
member("manager", NamespaceRole.ADMIN), member("ordinary-member", NamespaceRole.MEMBER)));
|
||||
enablePersistenceFor("manager", "SUBSCRIPTION_NEW_VERSION");
|
||||
|
||||
listener.onSkillPublishedForSubscribers(new SkillPublishedEvent(SKILL_ID, VERSION_ID, "publisher"));
|
||||
|
||||
String body = "{\"skillId\":1,\"skillName\":\"Test Skill\",\"slug\":\"test-skill\",\"namespace\":\"demo\"}";
|
||||
verify(notificationService).create("manager", NotificationCategory.PUBLISH,
|
||||
"SUBSCRIPTION_NEW_VERSION", "Skill updated: Test Skill", body, "SKILL", SKILL_ID);
|
||||
assertSingleSse("manager", "SUBSCRIPTION_NEW_VERSION", body);
|
||||
verify(accountRepository).findByIdIn(candidates);
|
||||
verify(memberRepository).findByNamespaceIdAndUserIdIn(NAMESPACE_ID, candidates);
|
||||
}
|
||||
|
||||
@ParameterizedTest(name = "yank wasPublished with fallback={0} reaches only current archived-namespace member")
|
||||
@ValueSource(booleans = {true, false})
|
||||
void yankPersistsAndPushesOnlyCurrentMemberForFallbackAndNoFallback(boolean hasFallback) {
|
||||
Skill skill = skill(SkillVisibility.PUBLIC, false, hasFallback ? 9L : null);
|
||||
Namespace namespace = namespace(NamespaceStatus.ARCHIVED);
|
||||
List<String> candidates = List.of("actor", "current", "removed", "inactive", "missing");
|
||||
arrangeEvent(skill, namespace, candidates);
|
||||
when(accountRepository.findByIdIn(candidates)).thenReturn(List.of(
|
||||
account("actor"), account("current"), account("removed"), inactiveAccount("inactive")));
|
||||
when(memberRepository.findByNamespaceIdAndUserIdIn(NAMESPACE_ID, candidates)).thenReturn(List.of(
|
||||
member("actor", NamespaceRole.ADMIN), member("current", NamespaceRole.MEMBER)));
|
||||
enablePersistenceFor("current", "SUBSCRIPTION_VERSION_YANKED");
|
||||
|
||||
listener.onSkillVersionYankedForSubscribers(
|
||||
new SkillVersionYankedEvent(SKILL_ID, VERSION_ID, "actor", true));
|
||||
|
||||
String body = "{\"skillId\":1,\"skillName\":\"Test Skill\",\"slug\":\"test-skill\",\"namespace\":\"demo\"}";
|
||||
verify(notificationService).create("current", NotificationCategory.PUBLISH,
|
||||
"SUBSCRIPTION_VERSION_YANKED", "Skill version yanked: Test Skill", body, "SKILL", SKILL_ID);
|
||||
assertSingleSse("current", "SUBSCRIPTION_VERSION_YANKED", body);
|
||||
verify(accountRepository).findByIdIn(candidates);
|
||||
verify(memberRepository).findByNamespaceIdAndUserIdIn(NAMESPACE_ID, candidates);
|
||||
}
|
||||
|
||||
@Test
|
||||
void yankWithoutVerifiedPublishedPreStateProducesNoPersistenceOrSse() {
|
||||
Skill skill = skill(SkillVisibility.PUBLIC, false, null);
|
||||
Namespace namespace = namespace(NamespaceStatus.ACTIVE);
|
||||
List<String> candidates = List.of("current");
|
||||
arrangeEvent(skill, namespace, candidates);
|
||||
when(accountRepository.findByIdIn(candidates)).thenReturn(List.of(account("current")));
|
||||
when(memberRepository.findByNamespaceIdAndUserIdIn(NAMESPACE_ID, candidates)).thenReturn(List.of());
|
||||
|
||||
listener.onSkillVersionYankedForSubscribers(
|
||||
new SkillVersionYankedEvent(SKILL_ID, VERSION_ID, "actor", false));
|
||||
|
||||
verifyNoInteractions(notificationService, preferenceService, sseEmitterManager);
|
||||
verify(accountRepository).findByIdIn(candidates);
|
||||
verify(memberRepository).findByNamespaceIdAndUserIdIn(NAMESPACE_ID, candidates);
|
||||
}
|
||||
|
||||
@ParameterizedTest(name = "{0} batch failure happens before every final sink")
|
||||
@EnumSource(BatchFailure.class)
|
||||
void authoritativeBatchFailureProducesNoPartialPersistenceOrSse(BatchFailure failure) {
|
||||
Skill skill = skill(SkillVisibility.PUBLIC, false, VERSION_ID);
|
||||
Namespace namespace = namespace(NamespaceStatus.ACTIVE);
|
||||
List<String> candidates = List.of("first", "second");
|
||||
when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill));
|
||||
when(subscriptionService.findSubscribersBySkillId(SKILL_ID)).thenReturn(candidates);
|
||||
if (failure == BatchFailure.NAMESPACE) {
|
||||
when(namespaceRepository.findById(NAMESPACE_ID)).thenThrow(new IllegalStateException("namespace batch"));
|
||||
} else {
|
||||
when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(namespace));
|
||||
if (failure == BatchFailure.ACCOUNT) {
|
||||
when(accountRepository.findByIdIn(candidates)).thenThrow(new IllegalStateException("account batch"));
|
||||
} else {
|
||||
when(accountRepository.findByIdIn(candidates)).thenReturn(List.of(account("first"), account("second")));
|
||||
when(memberRepository.findByNamespaceIdAndUserIdIn(NAMESPACE_ID, candidates))
|
||||
.thenThrow(new IllegalStateException("membership batch"));
|
||||
}
|
||||
}
|
||||
|
||||
assertThatThrownBy(() -> listener.onSkillPublishedForSubscribers(
|
||||
new SkillPublishedEvent(SKILL_ID, VERSION_ID, "publisher")))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining(failure.name().toLowerCase());
|
||||
|
||||
verifyNoInteractions(notificationService, preferenceService, sseEmitterManager);
|
||||
verify(namespaceRepository, times(1)).findById(NAMESPACE_ID);
|
||||
if (failure == BatchFailure.NAMESPACE) {
|
||||
verify(accountRepository, never()).findByIdIn(anyList());
|
||||
verify(memberRepository, never()).findByNamespaceIdAndUserIdIn(any(), anyCollection());
|
||||
} else {
|
||||
verify(accountRepository, times(1)).findByIdIn(candidates);
|
||||
verify(memberRepository, failure == BatchFailure.MEMBERSHIP ? times(1) : never())
|
||||
.findByNamespaceIdAndUserIdIn(eq(NAMESPACE_ID), anyCollection());
|
||||
}
|
||||
}
|
||||
|
||||
private void arrangeEvent(Skill skill, Namespace namespace, List<String> candidates) {
|
||||
when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill));
|
||||
when(subscriptionService.findSubscribersBySkillId(SKILL_ID)).thenReturn(candidates);
|
||||
when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(namespace));
|
||||
}
|
||||
|
||||
private void enablePersistenceFor(String recipient, String eventType) {
|
||||
when(preferenceService.isEnabled(recipient, NotificationCategory.PUBLISH, NotificationChannel.IN_APP))
|
||||
.thenReturn(true);
|
||||
when(notificationService.create(eq(recipient), eq(NotificationCategory.PUBLISH), eq(eventType),
|
||||
any(String.class), any(String.class), eq("SKILL"), eq(SKILL_ID)))
|
||||
.thenAnswer(invocation -> notification(recipient, eventType,
|
||||
invocation.getArgument(3), invocation.getArgument(4)));
|
||||
}
|
||||
|
||||
private void assertSingleSse(String recipient, String eventType, String body) {
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<Map<String, Object>> payload = ArgumentCaptor.forClass(Map.class);
|
||||
verify(sseEmitterManager).push(eq(recipient), payload.capture());
|
||||
assertThat(payload.getValue())
|
||||
.containsEntry("id", 42L)
|
||||
.containsEntry("category", "PUBLISH")
|
||||
.containsEntry("eventType", eventType)
|
||||
.containsEntry("bodyJson", body)
|
||||
.containsEntry("entityType", "SKILL")
|
||||
.containsEntry("entityId", SKILL_ID);
|
||||
}
|
||||
|
||||
private Skill skill(SkillVisibility visibility, boolean hidden, Long latestVersionId) {
|
||||
Skill skill = new Skill(NAMESPACE_ID, "test-skill", "publisher", visibility);
|
||||
skill.setDisplayName("Test Skill");
|
||||
skill.setHidden(hidden);
|
||||
skill.setLatestVersionId(latestVersionId);
|
||||
setId(skill, SKILL_ID);
|
||||
return skill;
|
||||
}
|
||||
|
||||
private Namespace namespace(NamespaceStatus status) {
|
||||
Namespace namespace = new Namespace("demo", "Demo", "publisher");
|
||||
namespace.setStatus(status);
|
||||
return namespace;
|
||||
}
|
||||
|
||||
private UserAccount account(String id) {
|
||||
return new UserAccount(id, id, null, null);
|
||||
}
|
||||
|
||||
private UserAccount inactiveAccount(String id) {
|
||||
UserAccount account = account(id);
|
||||
account.setStatus(UserStatus.DISABLED);
|
||||
return account;
|
||||
}
|
||||
|
||||
private NamespaceMember member(String userId, NamespaceRole role) {
|
||||
return new NamespaceMember(NAMESPACE_ID, userId, role);
|
||||
}
|
||||
|
||||
private Notification notification(String recipient, String eventType, String title, String body) {
|
||||
Notification notification = new Notification(recipient, NotificationCategory.PUBLISH, eventType,
|
||||
title, body, "SKILL", SKILL_ID, CREATED_AT);
|
||||
setId(notification, 42L);
|
||||
return notification;
|
||||
}
|
||||
|
||||
private void setId(Object entity, Long id) {
|
||||
try {
|
||||
var field = entity.getClass().getDeclaredField("id");
|
||||
field.setAccessible(true);
|
||||
field.set(entity, id);
|
||||
} catch (ReflectiveOperationException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private enum BatchFailure {
|
||||
ACCOUNT,
|
||||
NAMESPACE,
|
||||
MEMBERSHIP
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
package com.iflytek.skillhub.domain.event;
|
||||
|
||||
public record SkillVersionYankedEvent(Long skillId, Long versionId, String actorUserId) {}
|
||||
public record SkillVersionYankedEvent(Long skillId, Long versionId, String actorUserId, boolean wasPublished) {}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ public interface NamespaceMemberRepository {
|
|||
List<NamespaceMember> findByUserId(String userId);
|
||||
Page<NamespaceMember> findByNamespaceId(Long namespaceId, Pageable pageable);
|
||||
List<NamespaceMember> findByNamespaceIdAndRoleIn(Long namespaceId, Collection<NamespaceRole> roles);
|
||||
List<NamespaceMember> findByNamespaceIdAndUserIdIn(Long namespaceId, Collection<String> userIds);
|
||||
NamespaceMember save(NamespaceMember member);
|
||||
void deleteByNamespaceId(Long namespaceId);
|
||||
void deleteByNamespaceIdAndUserId(Long namespaceId, String userId);
|
||||
|
|
|
|||
|
|
@ -281,7 +281,7 @@ public class SkillGovernanceService {
|
|||
});
|
||||
auditLogService.record(actorUserId, "YANK_SKILL_VERSION", "SKILL_VERSION", versionId, null, clientIp, userAgent, jsonReason(reason));
|
||||
eventPublisher.publishEvent(new com.iflytek.skillhub.domain.event.SkillVersionYankedEvent(
|
||||
version.getSkillId(), versionId, actorUserId));
|
||||
version.getSkillId(), versionId, actorUserId, true));
|
||||
return saved;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@ package com.iflytek.skillhub.domain.social;
|
|||
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
|
||||
import com.iflytek.skillhub.domain.skill.SkillRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.*;
|
||||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
import com.iflytek.skillhub.domain.user.UserAccountRepository;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
|
||||
import com.iflytek.skillhub.domain.social.event.SkillSubscribedEvent;
|
||||
import com.iflytek.skillhub.domain.social.event.SkillUnsubscribedEvent;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
|
|
@ -9,24 +13,46 @@ import org.springframework.stereotype.Service;
|
|||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
public class SkillSubscriptionService {
|
||||
private final SkillSubscriptionRepository subscriptionRepository;
|
||||
private final SkillRepository skillRepository;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
private final NamespaceRepository namespaceRepository;
|
||||
private final NamespaceMemberRepository memberRepository;
|
||||
private final UserAccountRepository accountRepository;
|
||||
private final SubscriptionMetadataAccessPolicy accessPolicy;
|
||||
|
||||
public SkillSubscriptionService(SkillSubscriptionRepository subscriptionRepository,
|
||||
SkillRepository skillRepository,
|
||||
ApplicationEventPublisher eventPublisher) {
|
||||
ApplicationEventPublisher eventPublisher,
|
||||
NamespaceRepository namespaceRepository,
|
||||
NamespaceMemberRepository memberRepository,
|
||||
UserAccountRepository accountRepository,
|
||||
SubscriptionMetadataAccessPolicy accessPolicy) {
|
||||
this.subscriptionRepository = subscriptionRepository;
|
||||
this.skillRepository = skillRepository;
|
||||
this.eventPublisher = eventPublisher;
|
||||
this.namespaceRepository = namespaceRepository;
|
||||
this.memberRepository = memberRepository;
|
||||
this.accountRepository = accountRepository;
|
||||
this.accessPolicy = accessPolicy;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void subscribe(Long skillId, String userId) {
|
||||
ensureSkillExists(skillId);
|
||||
var skill = skillRepository.findById(skillId)
|
||||
.orElseThrow(() -> new DomainNotFoundException("skill.not_found", skillId));
|
||||
UserAccount account = accountRepository.findById(userId).orElse(null);
|
||||
Namespace namespace = namespaceRepository.findById(skill.getNamespaceId()).orElse(null);
|
||||
var role = memberRepository.findByNamespaceIdAndUserId(skill.getNamespaceId(), userId)
|
||||
.map(NamespaceMember::getRole);
|
||||
Map<Long, NamespaceRole> roles = role.map(value -> Map.of(skill.getNamespaceId(), value)).orElse(Map.of());
|
||||
if (!accessPolicy.canAccessCurrent(skill, namespace, account, roles)) {
|
||||
throw new DomainForbiddenException("error.skill.subscription.noPermission");
|
||||
}
|
||||
if (subscriptionRepository.findBySkillIdAndUserId(skillId, userId).isPresent()) {
|
||||
return; // idempotent
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
package com.iflytek.skillhub.domain.social;
|
||||
|
||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
|
||||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
||||
import com.iflytek.skillhub.domain.skill.VisibilityChecker;
|
||||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
|
||||
import java.util.Map;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** Authorization for metadata exposed by subscriptions and subscriber notifications. */
|
||||
@Component
|
||||
public class SubscriptionMetadataAccessPolicy {
|
||||
private final VisibilityChecker visibilityChecker;
|
||||
|
||||
public SubscriptionMetadataAccessPolicy() {
|
||||
this(new VisibilityChecker());
|
||||
}
|
||||
|
||||
@Autowired
|
||||
public SubscriptionMetadataAccessPolicy(VisibilityChecker visibilityChecker) {
|
||||
this.visibilityChecker = visibilityChecker;
|
||||
}
|
||||
|
||||
public boolean canAccessCurrent(Skill skill, Namespace namespace, UserAccount account,
|
||||
Map<Long, NamespaceRole> namespaceRoles) {
|
||||
if (account == null || !account.isActive() || namespace == null) {
|
||||
return false;
|
||||
}
|
||||
if (namespace.getStatus() == NamespaceStatus.ARCHIVED
|
||||
&& !isOwnerOrManager(skill, account, namespaceRoles)) {
|
||||
return false;
|
||||
}
|
||||
return visibilityChecker.canAccess(skill, account.getId(), namespaceRoles);
|
||||
}
|
||||
|
||||
public boolean canAccessYankedPublication(Skill skill, Namespace namespace, UserAccount account,
|
||||
Map<Long, NamespaceRole> namespaceRoles,
|
||||
boolean wasPublished) {
|
||||
return wasPublished && canAccess(skill, namespace, account, namespaceRoles, true);
|
||||
}
|
||||
|
||||
private boolean canAccess(Skill skill, Namespace namespace, UserAccount account,
|
||||
Map<Long, NamespaceRole> namespaceRoles, boolean yankedPublication) {
|
||||
if (account == null || !account.isActive() || namespace == null) {
|
||||
return false;
|
||||
}
|
||||
Map<Long, NamespaceRole> roles = namespaceRoles == null ? Map.of() : namespaceRoles;
|
||||
NamespaceRole role = roles.get(skill.getNamespaceId());
|
||||
if (namespace.getStatus() == NamespaceStatus.ARCHIVED && role == null) {
|
||||
return false;
|
||||
}
|
||||
boolean owner = skill.getOwnerId().equals(account.getId());
|
||||
boolean manager = role == NamespaceRole.ADMIN || role == NamespaceRole.OWNER;
|
||||
if (skill.isHidden()) {
|
||||
return owner || manager;
|
||||
}
|
||||
if (!yankedPublication && skill.getLatestVersionId() == null) {
|
||||
return owner;
|
||||
}
|
||||
return switch (skill.getVisibility()) {
|
||||
case PUBLIC -> true;
|
||||
case NAMESPACE_ONLY -> role != null;
|
||||
case PRIVATE -> owner || manager;
|
||||
};
|
||||
}
|
||||
|
||||
private boolean isOwnerOrManager(Skill skill, UserAccount account,
|
||||
Map<Long, NamespaceRole> namespaceRoles) {
|
||||
if (skill.getOwnerId().equals(account.getId())) {
|
||||
return true;
|
||||
}
|
||||
NamespaceRole role = namespaceRoles == null ? null : namespaceRoles.get(skill.getNamespaceId());
|
||||
return role == NamespaceRole.ADMIN || role == NamespaceRole.OWNER;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
package com.iflytek.skillhub.domain.social;
|
||||
|
||||
import com.iflytek.skillhub.domain.namespace.*;
|
||||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
import com.iflytek.skillhub.domain.user.UserAccountRepository;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.function.BiPredicate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** Loads authoritative account and membership facts in batches before selecting recipients. */
|
||||
@Component
|
||||
public class SubscriptionRecipientEligibility {
|
||||
private final UserAccountRepository accountRepository;
|
||||
private final NamespaceMemberRepository memberRepository;
|
||||
private final SubscriptionMetadataAccessPolicy policy;
|
||||
|
||||
public SubscriptionRecipientEligibility(UserAccountRepository accountRepository,
|
||||
NamespaceMemberRepository memberRepository,
|
||||
SubscriptionMetadataAccessPolicy policy) {
|
||||
this.accountRepository = accountRepository;
|
||||
this.memberRepository = memberRepository;
|
||||
this.policy = policy;
|
||||
}
|
||||
|
||||
public List<String> currentRecipients(Skill skill, Namespace namespace, List<String> candidateIds) {
|
||||
return eligible(skill, namespace, candidateIds,
|
||||
(account, roles) -> policy.canAccessCurrent(skill, namespace, account, roles));
|
||||
}
|
||||
|
||||
public List<String> yankedRecipients(Skill skill, Namespace namespace, List<String> candidateIds,
|
||||
boolean wasPublished) {
|
||||
return eligible(skill, namespace, candidateIds,
|
||||
(account, roles) -> policy.canAccessYankedPublication(skill, namespace, account, roles, wasPublished));
|
||||
}
|
||||
|
||||
private List<String> eligible(Skill skill, Namespace namespace, List<String> candidateIds,
|
||||
BiPredicate<UserAccount, Map<Long, NamespaceRole>> predicate) {
|
||||
List<String> ids = candidateIds.stream().distinct().toList();
|
||||
if (ids.isEmpty()) return List.of();
|
||||
Map<String, UserAccount> accounts = new HashMap<>();
|
||||
accountRepository.findByIdIn(ids).forEach(account -> accounts.put(account.getId(), account));
|
||||
Map<String, NamespaceRole> roles = new HashMap<>();
|
||||
memberRepository.findByNamespaceIdAndUserIdIn(skill.getNamespaceId(), ids)
|
||||
.forEach(member -> roles.put(member.getUserId(), member.getRole()));
|
||||
return ids.stream().filter(id -> {
|
||||
NamespaceRole role = roles.get(id);
|
||||
Map<Long, NamespaceRole> roleMap = role == null ? Map.of() : Map.of(skill.getNamespaceId(), role);
|
||||
return predicate.test(accounts.get(id), roleMap);
|
||||
}).toList();
|
||||
}
|
||||
}
|
||||
|
|
@ -214,6 +214,25 @@ class SkillDownloadServiceTest {
|
|||
verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void downloadRejectsPublicArchivedSkillEvenThoughItsMetadataRemainsReadable() throws Exception {
|
||||
Namespace namespace = new Namespace("global", "Global", "owner-1");
|
||||
setId(namespace, 1L);
|
||||
Skill skill = new Skill(1L, "archived-skill", "owner-1", SkillVisibility.PUBLIC);
|
||||
setId(skill, 9L);
|
||||
skill.setStatus(SkillStatus.ARCHIVED);
|
||||
skill.setLatestVersionId(10L);
|
||||
when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, "archived-skill")).thenReturn(List.of(skill));
|
||||
|
||||
assertThrows(com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException.class, () ->
|
||||
service.downloadLatest("global", "archived-skill", "viewer", Map.of()));
|
||||
|
||||
verify(skillRepository, never()).incrementDownloadCount(anyLong());
|
||||
verify(skillVersionStatsRepository, never()).incrementDownloadCount(anyLong(), anyLong());
|
||||
verify(eventPublisher, never()).publishEvent(any(SkillDownloadedEvent.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDownloadLatest_ShouldRejectAnonymousHiddenPrivateAndUnpublishedSkills() throws Exception {
|
||||
Namespace namespace = new Namespace("global", "Global", "owner-1");
|
||||
|
|
|
|||
|
|
@ -202,6 +202,64 @@ class SkillQueryServiceTest {
|
|||
service.getSkillDetail(namespaceSlug, skillSlug, null, Map.of()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSkillDetailKeepsPublicArchivedSkillDirectlyReadableWithArchivedStatus() throws Exception {
|
||||
Namespace namespace = new Namespace("global", "Global", "owner-1");
|
||||
setId(namespace, 1L);
|
||||
Skill skill = new Skill(1L, "archived-skill", "owner-1", SkillVisibility.PUBLIC);
|
||||
setId(skill, 9L);
|
||||
skill.setStatus(SkillStatus.ARCHIVED);
|
||||
skill.setLatestVersionId(10L);
|
||||
SkillVersion version = new SkillVersion(9L, "1.0.0", "owner-1");
|
||||
setId(version, 10L);
|
||||
version.setStatus(SkillVersionStatus.PUBLISHED);
|
||||
when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, "archived-skill")).thenReturn(List.of(skill));
|
||||
when(skillVersionRepository.findById(10L)).thenReturn(Optional.of(version));
|
||||
when(userAccountRepository.findById("owner-1"))
|
||||
.thenReturn(Optional.of(new UserAccount("owner-1", "Owner", null, null)));
|
||||
|
||||
SkillQueryService.SkillDetailDTO detail =
|
||||
service.getSkillDetail("global", "archived-skill", "viewer", Map.of());
|
||||
|
||||
assertEquals("ARCHIVED", detail.status());
|
||||
assertEquals("archived-skill", detail.slug());
|
||||
}
|
||||
|
||||
@Test
|
||||
void namespaceDiscoveryQueriesOnlyActiveSkillsSoArchivedSkillIsAbsent() throws Exception {
|
||||
Namespace namespace = new Namespace("global", "Global", "owner-1");
|
||||
setId(namespace, 1L);
|
||||
when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndStatus(1L, SkillStatus.ACTIVE)).thenReturn(List.of());
|
||||
|
||||
Page<Skill> result = service.listSkillsByNamespace("global", "viewer", Map.of(), PageRequest.of(0, 20));
|
||||
|
||||
assertTrue(result.isEmpty());
|
||||
verify(skillRepository).findByNamespaceIdAndStatus(1L, SkillStatus.ACTIVE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void versionContentRejectsPublicArchivedSkillForNonManager() throws Exception {
|
||||
Namespace namespace = new Namespace("global", "Global", "owner-1");
|
||||
setId(namespace, 1L);
|
||||
Skill skill = new Skill(1L, "archived-skill", "owner-1", SkillVisibility.PUBLIC);
|
||||
setId(skill, 9L);
|
||||
skill.setStatus(SkillStatus.ARCHIVED);
|
||||
skill.setLatestVersionId(10L);
|
||||
SkillVersion version = new SkillVersion(9L, "1.0.0", "owner-1");
|
||||
setId(version, 10L);
|
||||
version.setStatus(SkillVersionStatus.PUBLISHED);
|
||||
when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(1L, "archived-skill")).thenReturn(List.of(skill));
|
||||
lenient().when(skillVersionRepository.findBySkillIdAndVersion(9L, "1.0.0"))
|
||||
.thenReturn(Optional.of(version));
|
||||
|
||||
assertThrows(DomainForbiddenException.class, () ->
|
||||
service.listFiles("global", "archived-skill", "1.0.0", "viewer", Map.of()));
|
||||
verifyNoInteractions(skillFileRepository);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testListSkillsByNamespace() throws Exception {
|
||||
// Arrange
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ public interface NamespaceMemberJpaRepository
|
|||
List<NamespaceMember> findByUserId(String userId);
|
||||
Page<NamespaceMember> findByNamespaceId(Long namespaceId, Pageable pageable);
|
||||
List<NamespaceMember> findByNamespaceIdAndRoleIn(Long namespaceId, Collection<NamespaceRole> roles);
|
||||
List<NamespaceMember> findByNamespaceIdAndUserIdIn(Long namespaceId, Collection<String> userIds);
|
||||
void deleteByNamespaceId(Long namespaceId);
|
||||
void deleteByNamespaceIdAndUserId(Long namespaceId, String userId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,11 @@ import org.mockito.Mock;
|
|||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
|
@ -58,6 +63,41 @@ class NotificationDispatcherTest {
|
|||
verify(sseEmitterManager).push(eq("user-1"), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void dispatch_persistsExactSubscriberNotificationAndPushesSameRecipientVisiblePayload() {
|
||||
Notification notification = new Notification("subscriber-1", NotificationCategory.PUBLISH,
|
||||
"SUBSCRIPTION_NEW_VERSION", "Skill updated: Demo",
|
||||
"{\"skillId\":1,\"versionId\":10}", "SKILL", 1L,
|
||||
Instant.parse("2026-08-19T20:30:00Z"));
|
||||
try {
|
||||
var id = Notification.class.getDeclaredField("id");
|
||||
id.setAccessible(true);
|
||||
id.set(notification, 42L);
|
||||
} catch (ReflectiveOperationException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
when(preferenceService.isEnabled("subscriber-1", NotificationCategory.PUBLISH,
|
||||
NotificationChannel.IN_APP)).thenReturn(true);
|
||||
when(notificationService.create(any(), any(), any(), any(), any(), any(), any()))
|
||||
.thenReturn(notification);
|
||||
|
||||
dispatcher.dispatch("subscriber-1", NotificationCategory.PUBLISH, "SUBSCRIPTION_NEW_VERSION",
|
||||
"Skill updated: Demo", "{\"skillId\":1,\"versionId\":10}", "SKILL", 1L);
|
||||
|
||||
verify(notificationService).create("subscriber-1", NotificationCategory.PUBLISH,
|
||||
"SUBSCRIPTION_NEW_VERSION", "Skill updated: Demo",
|
||||
"{\"skillId\":1,\"versionId\":10}", "SKILL", 1L);
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<Map<String, Object>> payload = ArgumentCaptor.forClass(Map.class);
|
||||
verify(sseEmitterManager).push(eq("subscriber-1"), payload.capture());
|
||||
assertThat(payload.getValue()).containsEntry("id", 42L)
|
||||
.containsEntry("category", "PUBLISH")
|
||||
.containsEntry("eventType", "SUBSCRIPTION_NEW_VERSION")
|
||||
.containsEntry("bodyJson", "{\"skillId\":1,\"versionId\":10}")
|
||||
.containsEntry("entityType", "SKILL")
|
||||
.containsEntry("entityId", 1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void dispatch_shouldSkipWhenPreferenceDisabled() {
|
||||
when(preferenceService.isEnabled("user-1", NotificationCategory.REVIEW, NotificationChannel.IN_APP))
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue