fix(team): enforce workspace isolation (#593)

This commit is contained in:
mateaix 2026-08-09 21:00:29 +08:00
parent 54eb77d7c7
commit 763fb554da
10 changed files with 359 additions and 43 deletions

View File

@ -5,6 +5,8 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.repository.AgentMapper;
import vip.mate.common.result.R;
@ -21,6 +23,7 @@ import vip.mate.team.service.TeamDispatchService;
import vip.mate.team.service.TeamEventChannel;
import vip.mate.team.service.TeamService;
import vip.mate.team.service.TeamTaskService;
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
import java.security.Principal;
import java.util.List;
@ -54,35 +57,32 @@ public class TeamController {
@Operation(summary = "团队列表")
@GetMapping
@RequireWorkspaceRole("viewer")
public R<List<TeamVO>> list() {
return R.ok(teamService.listTeams().stream().map(this::toVO).toList());
return R.ok(teamService.listTeams(currentWorkspaceId()).stream().map(this::toVO).toList());
}
@Operation(summary = "团队详情(含成员)")
@GetMapping("/{id}")
@RequireWorkspaceRole("viewer")
public R<TeamDetailVO> get(@PathVariable Long id) {
AgentTeamEntity team = teamService.getTeam(id);
AgentTeamEntity team = teamService.getTeam(id, currentWorkspaceId());
if (team == null) {
return R.fail("team not found");
}
List<MemberVO> members = teamService.listMembers(id).stream()
.map(m -> {
AgentEntity agent = agentMapper.selectById(m.getAgentId());
return new MemberVO(m.getAgentId(),
agent != null && agent.getName() != null ? agent.getName()
: String.valueOf(m.getAgentId()),
m.getRole(),
agent != null ? agent.getIcon() : null);
})
.map(m -> toMemberVO(team, m))
.filter(java.util.Objects::nonNull)
.toList();
return R.ok(new TeamDetailVO(toVO(team), members));
}
@Operation(summary = "创建团队")
@PostMapping
@RequireWorkspaceRole("admin")
public R<TeamVO> create(@RequestBody CreateTeamRequest req, Principal principal) {
return guarded(() -> {
AgentTeamEntity team = teamService.createTeam(req.getName(), req.getDescription(),
AgentTeamEntity team = teamService.createTeam(currentWorkspaceId(), req.getName(), req.getDescription(),
req.getLeadAgentId(), req.getMemberAgentIds(),
principal != null ? principal.getName() : "admin");
return R.ok(toVO(team));
@ -91,16 +91,18 @@ public class TeamController {
@Operation(summary = "更新团队")
@PutMapping("/{id}")
@RequireWorkspaceRole("admin")
public R<TeamVO> update(@PathVariable Long id, @RequestBody UpdateTeamRequest req) {
return guarded(() -> R.ok(toVO(teamService.updateTeam(id, req.getName(),
return guarded(() -> R.ok(toVO(teamService.updateTeam(id, currentWorkspaceId(), req.getName(),
req.getDescription(), req.getSettings()))));
}
@Operation(summary = "删除团队")
@DeleteMapping("/{id}")
@RequireWorkspaceRole("admin")
public R<Void> delete(@PathVariable Long id) {
return guarded(() -> {
teamService.deleteTeam(id);
teamService.deleteTeam(id, currentWorkspaceId());
return R.ok(null);
});
}
@ -109,18 +111,20 @@ public class TeamController {
@Operation(summary = "添加成员")
@PostMapping("/{id}/members")
@RequireWorkspaceRole("admin")
public R<Void> addMember(@PathVariable Long id, @RequestBody MemberRequest req) {
return guarded(() -> {
teamService.addMember(id, req.getAgentId(), req.getRole());
teamService.addMember(id, currentWorkspaceId(), req.getAgentId(), req.getRole());
return R.ok(null);
});
}
@Operation(summary = "移除成员")
@DeleteMapping("/{id}/members/{agentId}")
@RequireWorkspaceRole("admin")
public R<Void> removeMember(@PathVariable Long id, @PathVariable Long agentId) {
return guarded(() -> {
teamService.removeMember(id, agentId);
teamService.removeMember(id, currentWorkspaceId(), agentId);
return R.ok(null);
});
}
@ -129,18 +133,24 @@ public class TeamController {
@Operation(summary = "任务板列表")
@GetMapping("/{id}/tasks")
@RequireWorkspaceRole("viewer")
public R<List<TaskVO>> listTasks(@PathVariable Long id,
@RequestParam(required = false) List<String> status,
@RequestParam(required = false) Integer limit,
@RequestParam(required = false) Integer offset) {
return R.ok(taskService.listTasks(id, status, limit, offset).stream()
.map(this::toTaskVO).toList());
return guarded(() -> {
requireTeam(id);
return R.ok(taskService.listTasks(id, status, limit, offset).stream()
.map(this::toTaskVO).toList());
});
}
@Operation(summary = "任务详情(含评论)")
@GetMapping("/{id}/tasks/{taskId}")
@RequireWorkspaceRole("viewer")
public R<TaskDetailVO> getTask(@PathVariable Long id, @PathVariable Long taskId) {
return guarded(() -> {
requireTeam(id);
TeamTaskEntity task = requireTask(id, taskId);
return R.ok(new TaskDetailVO(toTaskVO(task), taskService.listComments(taskId)));
});
@ -148,9 +158,11 @@ public class TeamController {
@Operation(summary = "手动创建任务")
@PostMapping("/{id}/tasks")
@RequireWorkspaceRole("admin")
public R<TaskVO> createTask(@PathVariable Long id, @RequestBody CreateTaskRequest req,
Principal principal) {
return guarded(() -> {
requireTeam(id);
TeamTaskEntity task = taskService.createTask(TeamTaskCreateCommand.builder()
.teamId(id)
.subject(req.getSubject())
@ -172,9 +184,11 @@ public class TeamController {
@Operation(summary = "批准 in_review 任务")
@PostMapping("/{id}/tasks/{taskId}/approve")
@RequireWorkspaceRole("admin")
public R<TaskVO> approve(@PathVariable Long id, @PathVariable Long taskId,
Principal principal) {
return guarded(() -> {
requireTeam(id);
requireTask(id, taskId);
List<Long> released = taskService.approveTask(taskId);
recordUserEvent(id, taskId, TeamTaskEventEntity.APPROVED, principal, null);
@ -188,10 +202,12 @@ public class TeamController {
@Operation(summary = "驳回 in_review 任务")
@PostMapping("/{id}/tasks/{taskId}/reject")
@RequireWorkspaceRole("admin")
public R<TaskVO> reject(@PathVariable Long id, @PathVariable Long taskId,
@RequestBody(required = false) ReasonRequest req,
Principal principal) {
return guarded(() -> {
requireTeam(id);
requireTask(id, taskId);
taskService.rejectTask(taskId, req == null ? null : req.getReason());
recordUserEvent(id, taskId, TeamTaskEventEntity.REJECTED, principal,
@ -207,9 +223,11 @@ public class TeamController {
@Operation(summary = "重试 failed/stale 任务")
@PostMapping("/{id}/tasks/{taskId}/retry")
@RequireWorkspaceRole("admin")
public R<TaskVO> retry(@PathVariable Long id, @PathVariable Long taskId,
Principal principal) {
return guarded(() -> {
requireTeam(id);
requireTask(id, taskId);
if (!taskService.retryTask(taskId)) {
return R.fail("only failed or stale tasks can be retried");
@ -223,10 +241,12 @@ public class TeamController {
@Operation(summary = "取消任务")
@PostMapping("/{id}/tasks/{taskId}/cancel")
@RequireWorkspaceRole("admin")
public R<TaskVO> cancel(@PathVariable Long id, @PathVariable Long taskId,
@RequestBody(required = false) ReasonRequest req,
Principal principal) {
return guarded(() -> {
requireTeam(id);
TeamTaskEntity task = requireTask(id, taskId);
List<Long> released = taskService.cancelTask(taskId, req == null ? null : req.getReason());
recordUserEvent(id, taskId, TeamTaskEventEntity.CANCELLED, principal,
@ -243,8 +263,10 @@ public class TeamController {
@Operation(summary = "任务时间线")
@GetMapping("/{id}/tasks/{taskId}/events")
@RequireWorkspaceRole("viewer")
public R<List<TeamTaskEventEntity>> taskEvents(@PathVariable Long id, @PathVariable Long taskId) {
return guarded(() -> {
requireTeam(id);
requireTask(id, taskId);
return R.ok(taskService.listEvents(taskId));
});
@ -252,8 +274,10 @@ public class TeamController {
@Operation(summary = "团队事件流SSE")
@GetMapping("/{id}/events")
@RequireWorkspaceRole("viewer")
public SseEmitter events(@PathVariable Long id,
@RequestHeader(value = "Last-Event-ID", required = false) Long lastEventId) {
requireTeam(id);
SseEmitter emitter = new SseEmitter(0L);
// A fresh subscription is an activity ticker, not a transcript: skip
// the ring-buffer replay (stale events would render as breaking news)
@ -275,9 +299,11 @@ public class TeamController {
@Operation(summary = "添加评论")
@PostMapping("/{id}/tasks/{taskId}/comments")
@RequireWorkspaceRole("admin")
public R<Void> comment(@PathVariable Long id, @PathVariable Long taskId,
@RequestBody CommentRequest req, Principal principal) {
return guarded(() -> {
requireTeam(id);
requireTask(id, taskId);
taskService.addComment(taskId, TeamTaskService.AUTHOR_USER,
principal != null ? principal.getName() : "admin",
@ -288,8 +314,12 @@ public class TeamController {
@Operation(summary = "任务状态统计(看板列头)")
@GetMapping("/{id}/tasks/stats")
@RequireWorkspaceRole("viewer")
public R<Map<String, Long>> taskStats(@PathVariable Long id) {
return R.ok(taskService.countByStatus(id));
return guarded(() -> {
requireTeam(id);
return R.ok(taskService.countByStatus(id));
});
}
// ==================== helpers / DTOs ====================
@ -316,8 +346,32 @@ public class TeamController {
return task;
}
private AgentTeamEntity requireTeam(Long teamId) {
AgentTeamEntity team = teamService.getTeam(teamId, currentWorkspaceId());
if (team == null) {
throw new IllegalArgumentException("team not found: " + teamId);
}
return team;
}
private long currentWorkspaceId() {
if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attrs) {
String header = attrs.getRequest().getHeader("X-Workspace-Id");
if (header != null && !header.isBlank()) {
try {
return Long.parseLong(header.trim());
} catch (NumberFormatException ignored) {
// Keep the same defaulting semantics as WorkspaceAccessInterceptor.
}
}
}
return 1L;
}
private TeamVO toVO(AgentTeamEntity team) {
long memberCount = teamService.listMembers(team.getId()).size();
long memberCount = teamService.listMembers(team.getId()).stream()
.filter(member -> memberBelongsToWorkspace(team, member))
.count();
AgentEntity lead = agentMapper.selectById(team.getLeadAgentId());
return new TeamVO(team,
lead != null && lead.getName() != null ? lead.getName()
@ -326,6 +380,21 @@ public class TeamController {
memberCount);
}
private MemberVO toMemberVO(AgentTeamEntity team, AgentTeamMemberEntity member) {
AgentEntity agent = agentMapper.selectById(member.getAgentId());
if (agent == null || !team.getWorkspaceId().equals(agent.getWorkspaceId())) {
return null;
}
return new MemberVO(member.getAgentId(),
agent.getName() != null ? agent.getName() : String.valueOf(member.getAgentId()),
member.getRole(), agent.getIcon());
}
private boolean memberBelongsToWorkspace(AgentTeamEntity team, AgentTeamMemberEntity member) {
AgentEntity agent = agentMapper.selectById(member.getAgentId());
return agent != null && team.getWorkspaceId().equals(agent.getWorkspaceId());
}
private TaskVO toTaskVO(TeamTaskEntity task) {
return new TaskVO(task,
agentName(task.getAssigneeAgentId()),

View File

@ -23,6 +23,9 @@ public class AgentTeamEntity {
private String description;
/** Owning workspace. Team data is never shared across workspaces. */
private Long workspaceId;
/** Agent that orchestrates this team; exactly one per team. */
private Long leadAgentId;

View File

@ -108,7 +108,7 @@ public class TeamDispatchService {
@Scheduled(fixedDelay = 30_000, initialDelay = 30_000)
public void scheduledSweep() {
taskService.recoverStaleTasks();
for (AgentTeamEntity team : teamService.listTeams()) {
for (AgentTeamEntity team : teamService.listAllTeams()) {
if (TeamService.STATUS_ACTIVE.equals(team.getStatus())) {
try {
sweep(team.getId());

View File

@ -43,16 +43,16 @@ public class TeamService {
private final ApplicationEventPublisher eventPublisher;
@Transactional
public AgentTeamEntity createTeam(String name, String description, Long leadAgentId,
public AgentTeamEntity createTeam(Long workspaceId, String name, String description, Long leadAgentId,
List<Long> memberAgentIds, String createdBy) {
requireAgentExists(leadAgentId, "lead");
requireAgentInWorkspace(leadAgentId, workspaceId, "lead");
requireNotInAnyTeam(leadAgentId);
if (memberAgentIds != null) {
for (Long memberId : memberAgentIds) {
if (memberId.equals(leadAgentId)) {
throw new IllegalArgumentException("lead agent cannot also be listed as a member");
}
requireAgentExists(memberId, "member");
requireAgentInWorkspace(memberId, workspaceId, "member");
requireNotInAnyTeam(memberId);
}
}
@ -60,6 +60,7 @@ public class TeamService {
AgentTeamEntity team = new AgentTeamEntity();
team.setName(name);
team.setDescription(description);
team.setWorkspaceId(workspaceId);
team.setLeadAgentId(leadAgentId);
team.setStatus(STATUS_ACTIVE);
team.setTaskSeq(0);
@ -77,23 +78,23 @@ public class TeamService {
}
@Transactional
public void addMember(Long teamId, Long agentId, String role) {
AgentTeamEntity team = requireTeam(teamId);
public void addMember(Long teamId, Long workspaceId, Long agentId, String role) {
AgentTeamEntity team = requireTeam(teamId, workspaceId);
if (TeamRole.LEAD.equals(role)) {
throw new IllegalArgumentException("a team has exactly one lead; role must be member or reviewer");
}
if (agentId.equals(team.getLeadAgentId())) {
throw new IllegalArgumentException("agent is already the team lead");
}
requireAgentExists(agentId, "member");
requireAgentInWorkspace(agentId, workspaceId, "member");
requireNotInAnyTeam(agentId);
insertMember(teamId, agentId, role == null ? TeamRole.MEMBER : role);
notifyTeamChanged(teamId);
}
@Transactional
public void removeMember(Long teamId, Long agentId) {
AgentTeamEntity team = requireTeam(teamId);
public void removeMember(Long teamId, Long workspaceId, Long agentId) {
AgentTeamEntity team = requireTeam(teamId, workspaceId);
if (agentId.equals(team.getLeadAgentId())) {
throw new IllegalArgumentException("cannot remove the team lead; delete the team instead");
}
@ -106,8 +107,8 @@ public class TeamService {
}
@Transactional
public void deleteTeam(Long teamId) {
requireTeam(teamId);
public void deleteTeam(Long teamId, Long workspaceId) {
requireTeam(teamId, workspaceId);
// Capture membership before it is wiped so every agent gets evicted.
List<Long> agentIds = listMembers(teamId).stream()
.map(AgentTeamMemberEntity::getAgentId).toList();
@ -118,8 +119,8 @@ public class TeamService {
}
@Transactional
public AgentTeamEntity updateTeam(Long teamId, String name, String description, String settings) {
AgentTeamEntity team = requireTeam(teamId);
public AgentTeamEntity updateTeam(Long teamId, Long workspaceId, String name, String description, String settings) {
AgentTeamEntity team = requireTeam(teamId, workspaceId);
if (name != null) {
team.setName(name);
}
@ -134,7 +135,14 @@ public class TeamService {
return team;
}
public List<AgentTeamEntity> listTeams() {
public List<AgentTeamEntity> listTeams(Long workspaceId) {
return teamMapper.selectList(Wrappers.<AgentTeamEntity>lambdaQuery()
.eq(AgentTeamEntity::getWorkspaceId, workspaceId)
.orderByDesc(AgentTeamEntity::getCreateTime));
}
/** Internal scheduler view across all workspaces. Never expose through an HTTP endpoint. */
public List<AgentTeamEntity> listAllTeams() {
return teamMapper.selectList(Wrappers.<AgentTeamEntity>lambdaQuery()
.orderByDesc(AgentTeamEntity::getCreateTime));
}
@ -143,6 +151,12 @@ public class TeamService {
return teamMapper.selectById(teamId);
}
public AgentTeamEntity getTeam(Long teamId, Long workspaceId) {
return teamMapper.selectOne(Wrappers.<AgentTeamEntity>lambdaQuery()
.eq(AgentTeamEntity::getId, teamId)
.eq(AgentTeamEntity::getWorkspaceId, workspaceId));
}
public List<AgentTeamMemberEntity> listMembers(Long teamId) {
return memberMapper.selectList(Wrappers.<AgentTeamMemberEntity>lambdaQuery()
.eq(AgentTeamMemberEntity::getTeamId, teamId)
@ -154,6 +168,10 @@ public class TeamService {
* builder to inject team context and by the task tool to scope board access.
*/
public Optional<AgentTeamEntity> getTeamForAgent(Long agentId) {
AgentEntity agent = agentMapper.selectById(agentId);
if (agent == null || agent.getWorkspaceId() == null) {
return Optional.empty();
}
AgentTeamMemberEntity member = memberMapper.selectOne(Wrappers.<AgentTeamMemberEntity>lambdaQuery()
.eq(AgentTeamMemberEntity::getAgentId, agentId)
.last("LIMIT 1"));
@ -161,13 +179,20 @@ public class TeamService {
return Optional.empty();
}
AgentTeamEntity team = teamMapper.selectById(member.getTeamId());
if (team == null || !STATUS_ACTIVE.equals(team.getStatus())) {
if (team == null || !STATUS_ACTIVE.equals(team.getStatus())
|| !agent.getWorkspaceId().equals(team.getWorkspaceId())) {
return Optional.empty();
}
return Optional.of(team);
}
public boolean isMember(Long teamId, Long agentId) {
AgentTeamEntity team = teamMapper.selectById(teamId);
AgentEntity agent = agentMapper.selectById(agentId);
if (team == null || agent == null || team.getWorkspaceId() == null
|| !team.getWorkspaceId().equals(agent.getWorkspaceId())) {
return false;
}
return memberMapper.selectCount(Wrappers.<AgentTeamMemberEntity>lambdaQuery()
.eq(AgentTeamMemberEntity::getTeamId, teamId)
.eq(AgentTeamMemberEntity::getAgentId, agentId)) > 0;
@ -209,19 +234,22 @@ public class TeamService {
memberMapper.insert(member);
}
private AgentTeamEntity requireTeam(Long teamId) {
AgentTeamEntity team = teamMapper.selectById(teamId);
private AgentTeamEntity requireTeam(Long teamId, Long workspaceId) {
AgentTeamEntity team = getTeam(teamId, workspaceId);
if (team == null) {
throw new IllegalArgumentException("team not found: " + teamId);
}
return team;
}
private void requireAgentExists(Long agentId, String roleLabel) {
private void requireAgentInWorkspace(Long agentId, Long workspaceId, String roleLabel) {
AgentEntity agent = agentMapper.selectById(agentId);
if (agent == null) {
throw new IllegalArgumentException(roleLabel + " agent not found: " + agentId);
}
if (!workspaceId.equals(agent.getWorkspaceId())) {
throw new IllegalArgumentException(roleLabel + " agent does not belong to the current workspace: " + agentId);
}
}

View File

@ -0,0 +1,13 @@
-- Bind every team to one workspace. Existing teams inherit the lead agent's
-- workspace; rows with a missing/legacy lead remain in the default workspace.
ALTER TABLE mate_agent_team
ADD COLUMN IF NOT EXISTS workspace_id BIGINT NOT NULL DEFAULT 1;
UPDATE mate_agent_team t
SET workspace_id = COALESCE(
(SELECT a.workspace_id FROM mate_agent a WHERE a.id = t.lead_agent_id),
1
);
CREATE INDEX IF NOT EXISTS idx_agent_team_workspace
ON mate_agent_team (workspace_id, create_time);

View File

@ -0,0 +1,13 @@
-- Bind every team to one workspace. Existing teams inherit the lead agent's
-- workspace; rows with a missing/legacy lead remain in the default workspace.
ALTER TABLE mate_agent_team
ADD COLUMN IF NOT EXISTS workspace_id BIGINT NOT NULL DEFAULT 1;
UPDATE mate_agent_team t
SET workspace_id = COALESCE(
(SELECT a.workspace_id FROM mate_agent a WHERE a.id = t.lead_agent_id),
1
);
CREATE INDEX IF NOT EXISTS idx_agent_team_workspace
ON mate_agent_team (workspace_id, create_time);

View File

@ -0,0 +1,24 @@
-- Bind every team to one workspace. Existing teams inherit the lead agent's
-- workspace; rows with a missing/legacy lead remain in the default workspace.
SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'mate_agent_team'
AND COLUMN_NAME = 'workspace_id');
SET @s := IF(@c = 0,
'ALTER TABLE mate_agent_team ADD COLUMN workspace_id BIGINT NOT NULL DEFAULT 1 AFTER description',
'SELECT 1');
PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
UPDATE mate_agent_team t
JOIN mate_agent a ON a.id = t.lead_agent_id
SET t.workspace_id = a.workspace_id
WHERE a.workspace_id IS NOT NULL;
SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'mate_agent_team'
AND INDEX_NAME = 'idx_agent_team_workspace');
SET @s := IF(@c = 0,
'CREATE INDEX idx_agent_team_workspace ON mate_agent_team (workspace_id, create_time)',
'SELECT 1');
PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;

View File

@ -1,19 +1,26 @@
package vip.mate.team.controller;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import vip.mate.agent.repository.AgentMapper;
import vip.mate.auth.model.UserEntity;
import vip.mate.auth.service.AuthService;
import vip.mate.common.result.R;
import vip.mate.config.WorkspaceAccessInterceptor;
import vip.mate.team.model.TeamTaskCreateCommand;
import vip.mate.team.model.AgentTeamEntity;
import vip.mate.team.model.TeamTaskEntity;
import vip.mate.team.service.TeamAnnounceService;
import vip.mate.team.service.TeamDispatchService;
import vip.mate.team.service.TeamEventChannel;
import vip.mate.team.service.TeamService;
import vip.mate.team.service.TeamTaskService;
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
import vip.mate.workspace.core.service.WorkspaceService;
import java.util.List;
@ -27,6 +34,8 @@ import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* Verifies that service-layer validation verdicts (IllegalArgumentException /
@ -45,6 +54,8 @@ class TeamControllerTest {
@Mock private TeamAnnounceService announceService;
@Mock private TeamEventChannel eventChannel;
@Mock private AgentMapper agentMapper;
@Mock private WorkspaceService workspaceService;
@Mock private AuthService authService;
private TeamController controller;
@ -52,6 +63,15 @@ class TeamControllerTest {
void setUp() {
controller = new TeamController(teamService, taskService, dispatchService,
announceService, eventChannel, agentMapper);
AgentTeamEntity team = new AgentTeamEntity();
team.setId(TEAM_ID);
team.setWorkspaceId(1L);
org.mockito.Mockito.lenient().when(teamService.getTeam(TEAM_ID, 1L)).thenReturn(team);
}
@AfterEach
void clearSecurityContext() {
org.springframework.security.core.context.SecurityContextHolder.clearContext();
}
private TeamTaskEntity task(Long teamId, String status) {
@ -68,7 +88,7 @@ class TeamControllerTest {
@Test
void createTeamSurfacesMembershipConflictAsReadableFailure() {
when(teamService.createTeam(any(), any(), any(), any(), any()))
when(teamService.createTeam(anyLong(), any(), any(), any(), any(), any()))
.thenThrow(new IllegalStateException(
"agent 5 already belongs to team 2; an agent can join only one team"));
TeamController.CreateTeamRequest req = new TeamController.CreateTeamRequest();
@ -83,7 +103,7 @@ class TeamControllerTest {
@Test
void updateTeamSurfacesUnknownTeamAsReadableFailure() {
when(teamService.updateTeam(eq(TEAM_ID), any(), any(), any()))
when(teamService.updateTeam(eq(TEAM_ID), eq(1L), any(), any(), any()))
.thenThrow(new IllegalArgumentException("team not found: " + TEAM_ID));
R<TeamController.TeamVO> r = controller.update(TEAM_ID, new TeamController.UpdateTeamRequest());
@ -95,7 +115,7 @@ class TeamControllerTest {
@Test
void addMemberSurfacesValidationAsReadableFailure() {
doThrow(new IllegalArgumentException("agent is already the team lead"))
.when(teamService).addMember(TEAM_ID, 5L, "member");
.when(teamService).addMember(TEAM_ID, 1L, 5L, "member");
TeamController.MemberRequest req = new TeamController.MemberRequest();
req.setAgentId(5L);
req.setRole("member");
@ -109,7 +129,7 @@ class TeamControllerTest {
@Test
void removeMemberSurfacesLeadProtectionAsReadableFailure() {
doThrow(new IllegalArgumentException("cannot remove the team lead; delete the team instead"))
.when(teamService).removeMember(TEAM_ID, 5L);
.when(teamService).removeMember(TEAM_ID, 1L, 5L);
R<Void> r = controller.removeMember(TEAM_ID, 5L);
@ -240,4 +260,73 @@ class TeamControllerTest {
assertEquals(500, r.getCode());
assertEquals("task not found on this team's board", r.getMsg());
}
@Test
void crossWorkspaceTeamIsRejectedBeforeTaskBoardRead() {
when(teamService.getTeam(TEAM_ID, 1L)).thenReturn(null);
R<List<TeamController.TaskVO>> r = controller.listTasks(TEAM_ID, null, null, null);
assertEquals(500, r.getCode());
assertEquals("team not found: 1", r.getMsg());
verify(taskService, never()).listTasks(anyLong(), any(), any(), any());
}
@Test
void everyEndpointDeclaresWorkspaceRole() {
assertRole("list", "viewer");
assertRole("get", "viewer", Long.class);
assertRole("create", "admin", TeamController.CreateTeamRequest.class, java.security.Principal.class);
assertRole("update", "admin", Long.class, TeamController.UpdateTeamRequest.class);
assertRole("delete", "admin", Long.class);
assertRole("addMember", "admin", Long.class, TeamController.MemberRequest.class);
assertRole("removeMember", "admin", Long.class, Long.class);
assertRole("listTasks", "viewer", Long.class, List.class, Integer.class, Integer.class);
assertRole("getTask", "viewer", Long.class, Long.class);
assertRole("createTask", "admin", Long.class, TeamController.CreateTaskRequest.class, java.security.Principal.class);
assertRole("approve", "admin", Long.class, Long.class, java.security.Principal.class);
assertRole("reject", "admin", Long.class, Long.class, TeamController.ReasonRequest.class, java.security.Principal.class);
assertRole("retry", "admin", Long.class, Long.class, java.security.Principal.class);
assertRole("cancel", "admin", Long.class, Long.class, TeamController.ReasonRequest.class, java.security.Principal.class);
assertRole("taskEvents", "viewer", Long.class, Long.class);
assertRole("events", "viewer", Long.class, Long.class);
assertRole("comment", "admin", Long.class, Long.class, TeamController.CommentRequest.class, java.security.Principal.class);
assertRole("taskStats", "viewer", Long.class);
}
@Test
void viewerCannotDeleteTeam() throws Exception {
UserEntity viewer = new UserEntity();
viewer.setId(42L);
viewer.setUsername("viewer");
viewer.setRole("user");
when(authService.findByUsername("viewer")).thenReturn(viewer);
when(workspaceService.hasPermissionCached(1L, 42L, "admin")).thenReturn(false);
var authentication = new org.springframework.security.authentication.UsernamePasswordAuthenticationToken(
"viewer", "", List.of());
org.springframework.security.core.context.SecurityContextHolder.getContext()
.setAuthentication(authentication);
var mockMvc = org.springframework.test.web.servlet.setup.MockMvcBuilders
.standaloneSetup(controller)
.addInterceptors(new WorkspaceAccessInterceptor(workspaceService, authService, agentMapper))
.build();
mockMvc.perform(delete("/api/v1/teams/{id}", TEAM_ID)
.header("X-Workspace-Id", "1"))
.andExpect(status().isForbidden());
verify(teamService, never()).deleteTeam(anyLong(), anyLong());
}
private void assertRole(String method, String role, Class<?>... parameterTypes) {
try {
RequireWorkspaceRole annotation = TeamController.class
.getDeclaredMethod(method, parameterTypes)
.getAnnotation(RequireWorkspaceRole.class);
assertNotNull(annotation, method + " must require a workspace role");
assertEquals(role, annotation.value(), method + " role");
} catch (NoSuchMethodException e) {
throw new AssertionError(e);
}
}
}

View File

@ -16,6 +16,7 @@ import vip.mate.team.repository.AgentTeamMapper;
import vip.mate.team.repository.AgentTeamMemberMapper;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@ -58,6 +59,7 @@ class TeamServiceTest {
a.setId(id);
a.setName("agent-" + id);
a.setAgentType(agentType);
a.setWorkspaceId(1L);
return a;
}
@ -69,7 +71,80 @@ class TeamServiceTest {
when(memberMapper.selectCount(any())).thenReturn(0L);
assertDoesNotThrow(() ->
service.createTeam("", null, LEAD_ID, List.of(MEMBER_ID), "admin"));
verify(teamMapper).insert(any(AgentTeamEntity.class));
service.createTeam(1L, "", null, LEAD_ID, List.of(MEMBER_ID), "admin"));
org.mockito.ArgumentCaptor<AgentTeamEntity> captor =
org.mockito.ArgumentCaptor.forClass(AgentTeamEntity.class);
verify(teamMapper).insert(captor.capture());
assertEquals(1L, captor.getValue().getWorkspaceId());
}
@Test
void rejectsMemberFromAnotherWorkspace() {
AgentEntity lead = agent(LEAD_ID, "react");
AgentEntity member = agent(MEMBER_ID, "react");
member.setWorkspaceId(2L);
when(agentMapper.selectById(LEAD_ID)).thenReturn(lead);
when(agentMapper.selectById(MEMBER_ID)).thenReturn(member);
IllegalArgumentException error = assertThrows(IllegalArgumentException.class,
() -> service.createTeam(1L, "", null, LEAD_ID, List.of(MEMBER_ID), "admin"));
assertEquals("member agent does not belong to the current workspace: 2", error.getMessage());
verify(teamMapper, never()).insert(any(AgentTeamEntity.class));
}
@Test
void listTeamsAlwaysScopesByWorkspace() {
when(teamMapper.selectList(any())).thenReturn(List.of());
service.listTeams(7L);
@SuppressWarnings("rawtypes")
org.mockito.ArgumentCaptor<com.baomidou.mybatisplus.core.conditions.Wrapper> captor =
org.mockito.ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.Wrapper.class);
verify(teamMapper).selectList(captor.capture());
com.baomidou.mybatisplus.core.conditions.Wrapper<?> wrapper = captor.getValue();
assertTrue(wrapper.getSqlSegment().toLowerCase().contains("workspace"), wrapper.getSqlSegment());
@SuppressWarnings("unchecked")
com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<AgentTeamEntity> lambda =
(com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<AgentTeamEntity>) wrapper;
assertTrue(lambda.getParamNameValuePairs().containsValue(7L));
}
@Test
void agentCannotResolveTeamOwnedByAnotherWorkspace() {
AgentEntity agent = agent(LEAD_ID, "react");
AgentTeamMemberEntity membership = new AgentTeamMemberEntity();
membership.setTeamId(10L);
AgentTeamEntity foreignTeam = new AgentTeamEntity();
foreignTeam.setId(10L);
foreignTeam.setWorkspaceId(2L);
foreignTeam.setStatus(TeamService.STATUS_ACTIVE);
when(agentMapper.selectById(LEAD_ID)).thenReturn(agent);
when(memberMapper.selectOne(any())).thenReturn(membership);
when(teamMapper.selectById(10L)).thenReturn(foreignTeam);
Optional<AgentTeamEntity> result = service.getTeamForAgent(LEAD_ID);
assertTrue(result.isEmpty());
}
@Test
void addMemberRejectsAgentFromAnotherWorkspace() {
AgentTeamEntity team = new AgentTeamEntity();
team.setId(10L);
team.setLeadAgentId(LEAD_ID);
team.setWorkspaceId(1L);
AgentEntity foreignMember = agent(MEMBER_ID, "react");
foreignMember.setWorkspaceId(2L);
when(teamMapper.selectOne(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class)))
.thenReturn(team);
when(agentMapper.selectById(MEMBER_ID)).thenReturn(foreignMember);
IllegalArgumentException error = assertThrows(IllegalArgumentException.class,
() -> service.addMember(10L, 1L, MEMBER_ID, "member"));
assertEquals("member agent does not belong to the current workspace: 2", error.getMessage());
verify(memberMapper, never()).insert(any(AgentTeamMemberEntity.class));
}
}

View File

@ -20,7 +20,9 @@ export function subscribeTeamEvents(
const run = async () => {
const headers: Record<string, string> = { Accept: 'text/event-stream' }
const token = localStorage.getItem('token')
const workspaceId = localStorage.getItem('mc-workspace-id')
if (token) headers.Authorization = `Bearer ${token}`
if (workspaceId) headers['X-Workspace-Id'] = workspaceId
const res = await fetch(`/api/v1/teams/${teamId}/events`, {
headers,