diff --git a/mateclaw-server/src/main/java/vip/mate/approval/grant/controller/ApprovalGrantController.java b/mateclaw-server/src/main/java/vip/mate/approval/grant/controller/ApprovalGrantController.java new file mode 100644 index 00000000..5c4f7989 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/approval/grant/controller/ApprovalGrantController.java @@ -0,0 +1,351 @@ +package vip.mate.approval.grant.controller; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +import vip.mate.approval.grant.entity.ApprovalGrant; +import vip.mate.approval.grant.entity.ApprovalResolutionLog; +import vip.mate.approval.grant.repository.ApprovalGrantMapper; +import vip.mate.approval.grant.repository.ApprovalResolutionLogMapper; +import vip.mate.approval.grant.service.ApprovalGrantService; +import vip.mate.auth.model.UserEntity; +import vip.mate.auth.service.AuthService; +import vip.mate.common.result.R; +import vip.mate.exception.MateClawException; +import vip.mate.workspace.core.annotation.RequireWorkspaceRole; +import vip.mate.workspace.core.service.WorkspaceService; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * REST surface for the auto-grant subsystem. + *

+ * The header {@code @RequireWorkspaceRole("member")} is the minimum gate; the + * §2.4.5 6-cell authorization matrix is enforced inside each handler: + *

+ * + *

Snowflake id fields ({@code id} / {@code grantedBy} / {@code revokedBy} / + * {@code scopeId}) are serialized as strings by the global Jackson config so the + * frontend keeps them as strings end-to-end (see CLAUDE.md precision convention). + */ +@Tag(name = "自动批准策略") +@Slf4j +@RestController +@RequestMapping("/api/v1/approval") +@RequiredArgsConstructor +public class ApprovalGrantController { + + private static final long DEFAULT_WORKSPACE_ID = 1L; + + private final ApprovalGrantService grantService; + private final ApprovalGrantMapper grantMapper; + private final ApprovalResolutionLogMapper resolutionMapper; + private final AuthService authService; + private final WorkspaceService workspaceService; + + // ─── Create ───────────────────────────────────────────────────────── + + @Operation(summary = "创建自动批准策略") + @PostMapping("/grants") + @RequireWorkspaceRole("member") + public R create(@RequestBody CreateGrantRequest body, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId, + Authentication auth) { + Long actorId = resolveUserId(auth); + Long ws = workspaceId != null ? workspaceId : DEFAULT_WORKSPACE_ID; + + validate(body); + enforceCreationAuthorization(body, actorId, ws); + + ApprovalGrant grant = new ApprovalGrant(); + grant.setWorkspaceId(ws); + grant.setScopeType(body.scopeType); + grant.setScopeId(body.scopeId); + grant.setToolName(emptyToNull(body.toolName)); + grant.setRuleId(emptyToNull(body.ruleId)); + grant.setMaxSeverity(body.maxSeverity); + grant.setGrantKind(body.grantKind); + grant.setExpireAt(body.expireAt); + grant.setGrantedBy(actorId); + grant.setGrantedAt(LocalDateTime.now()); + grant.setRevoked(0); + grant.setDeleted(0); + grant.setNote(body.note); + + grantMapper.insert(grant); + log.info("[APPROVAL] Grant created: id={} scope={}/{} tool={} rule={} ceiling={} kind={} by user={}", + grant.getId(), grant.getScopeType(), grant.getScopeId(), + grant.getToolName(), grant.getRuleId(), grant.getMaxSeverity(), + grant.getGrantKind(), actorId); + return R.ok(grant); + } + + // ─── List ─────────────────────────────────────────────────────────── + + @Operation(summary = "列出当前 workspace 的自动批准策略") + @GetMapping("/grants") + @RequireWorkspaceRole("member") + public R> list( + @RequestParam(required = false) String scopeType, + @RequestParam(required = false) String toolName, + @RequestParam(required = false) Integer revoked, + @RequestParam(required = false, defaultValue = "false") boolean mine, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId, + Authentication auth) { + Long actorId = resolveUserId(auth); + Long ws = workspaceId != null ? workspaceId : DEFAULT_WORKSPACE_ID; + + // mine=false (看全部) 需要 admin;mine=true 任意 member 可以看自己的 + if (!mine) { + workspaceService.requirePermission(ws, actorId, "admin"); + } + + var wrapper = Wrappers.lambdaQuery() + .eq(ApprovalGrant::getWorkspaceId, ws) + .eq(ApprovalGrant::getDeleted, 0) + .orderByDesc(ApprovalGrant::getGrantedAt); + if (scopeType != null && !scopeType.isEmpty()) { + wrapper.eq(ApprovalGrant::getScopeType, scopeType); + } + if (toolName != null && !toolName.isEmpty()) { + wrapper.eq(ApprovalGrant::getToolName, toolName); + } + if (revoked != null) { + wrapper.eq(ApprovalGrant::getRevoked, revoked); + } + if (mine) { + wrapper.eq(ApprovalGrant::getGrantedBy, actorId); + } + return R.ok(grantMapper.selectList(wrapper)); + } + + // ─── Active summary (chip "(N)") ──────────────────────────────────── + + @Operation(summary = "当前 workspace 的活跃策略数量摘要") + @GetMapping("/grants/active") + @RequireWorkspaceRole("member") + public R> activeSummary( + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + Long ws = workspaceId != null ? workspaceId : DEFAULT_WORKSPACE_ID; + long count = grantService.countActiveInWorkspace(ws); + // hasWorkspaceWide: workspace + tool_name IS NULL — the dangerous one. + Long workspaceWide = grantMapper.selectCount( + Wrappers.lambdaQuery() + .eq(ApprovalGrant::getWorkspaceId, ws) + .eq(ApprovalGrant::getScopeType, ApprovalGrant.ScopeType.WORKSPACE) + .isNull(ApprovalGrant::getToolName) + .eq(ApprovalGrant::getRevoked, 0) + .eq(ApprovalGrant::getDeleted, 0) + .and(w -> w.isNull(ApprovalGrant::getExpireAt) + .or().gt(ApprovalGrant::getExpireAt, LocalDateTime.now()))); + Map out = new HashMap<>(); + out.put("count", count); + out.put("hasWorkspaceWide", workspaceWide != null && workspaceWide > 0); + return R.ok(out); + } + + // ─── Revoke ───────────────────────────────────────────────────────── + + @Operation(summary = "撤销自动批准策略") + @DeleteMapping("/grants/{id}") + @RequireWorkspaceRole("member") + public R revoke(@PathVariable Long id, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId, + Authentication auth) { + Long actorId = resolveUserId(auth); + Long ws = workspaceId != null ? workspaceId : DEFAULT_WORKSPACE_ID; + + ApprovalGrant existing = grantMapper.selectById(id); + if (existing == null || (existing.getDeleted() != null && existing.getDeleted() == 1)) { + throw new MateClawException("err.approval.grant_not_found", 404, "grant not found"); + } + if (!existing.getWorkspaceId().equals(ws)) { + // Cross-workspace lookup is treated as not-found to avoid leaking existence. + throw new MateClawException("err.approval.grant_not_found", 404, "grant not found"); + } + boolean isOwner = existing.getGrantedBy() != null && existing.getGrantedBy().equals(actorId); + boolean isAdmin = workspaceService.hasPermission(ws, actorId, "admin"); + if (!isOwner && !isAdmin) { + throw new MateClawException("err.approval.revoke_forbidden", 403, + "only the grant owner or a workspace admin can revoke"); + } + grantService.revoke(id, actorId); + log.info("[APPROVAL] Grant revoked: id={} by user={} (owner={}, admin={})", + id, actorId, isOwner, isAdmin); + return R.ok(); + } + + // ─── Resolutions read surface ─────────────────────────────────────── + + @Operation(summary = "查询审批最终决策日志(按 grantId 或 conversationId 过滤)") + @GetMapping("/resolutions") + @RequireWorkspaceRole("member") + public R> listResolutions( + @RequestParam(required = false) Long grantId, + @RequestParam(required = false) String conversationId, + @RequestParam(required = false, defaultValue = "100") int limit, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId, + Authentication auth) { + Long actorId = resolveUserId(auth); + Long ws = workspaceId != null ? workspaceId : DEFAULT_WORKSPACE_ID; + int cappedLimit = Math.min(Math.max(limit, 1), 500); + + // grantId queries are admin-only; conversationId queries (member view) just + // filter by membership of the workspace. + if (grantId != null) { + workspaceService.requirePermission(ws, actorId, "admin"); + } + + var wrapper = Wrappers.lambdaQuery() + .eq(ApprovalResolutionLog::getDeleted, 0) + .eq(ApprovalResolutionLog::getWorkspaceId, ws) + .orderByDesc(ApprovalResolutionLog::getCreateTime) + .last("LIMIT " + cappedLimit); + if (grantId != null) { + wrapper.eq(ApprovalResolutionLog::getGrantId, grantId); + } + if (conversationId != null && !conversationId.isEmpty()) { + wrapper.eq(ApprovalResolutionLog::getConversationId, conversationId); + } + return R.ok(resolutionMapper.selectList(wrapper)); + } + + // ─── Authorization matrix ─────────────────────────────────────────── + + /** + * Enforces the §2.4.5 6-cell matrix. Throws {@link MateClawException} with + * an HTTP 403 status when the actor is not permitted to create this scope. + * Password second-factor is checked for the two cells that require it. + */ + private void enforceCreationAuthorization(CreateGrantRequest body, Long actorId, Long workspaceId) { + String scope = body.scopeType; + boolean toolNull = body.toolName == null || body.toolName.isEmpty(); + boolean isAdmin = workspaceService.hasPermission(workspaceId, actorId, "admin"); + + switch (scope) { + case ApprovalGrant.ScopeType.CONVERSATION -> { + // Any member; nothing extra. + } + case ApprovalGrant.ScopeType.USER -> { + if (body.scopeId == null || !body.scopeId.equals(String.valueOf(actorId))) { + throw new MateClawException("err.approval.user_scope_self_only", 403, + "USER-scope grants can only target the requesting user"); + } + } + case ApprovalGrant.ScopeType.AGENT -> { + if (toolNull) { + requireAdminPlusPassword(isAdmin, body.password, actorId); + } else if (!isAdmin) { + // We don't currently model an "agent owner" surface here, so admin is the safe default. + // (Refining this to support agent owner is a v1.1 follow-up.) + workspaceService.requirePermission(workspaceId, actorId, "admin"); + } + } + case ApprovalGrant.ScopeType.WORKSPACE -> { + workspaceService.requirePermission(workspaceId, actorId, "admin"); + if (toolNull) { + requireAdminPlusPassword(true, body.password, actorId); + } + } + default -> throw new MateClawException("err.approval.invalid_scope", 400, + "unknown scope_type: " + scope); + } + } + + private void requireAdminPlusPassword(boolean isAdmin, String rawPassword, Long actorId) { + if (!isAdmin) { + throw new MateClawException("err.approval.admin_required", 403, "admin role required"); + } + if (rawPassword == null || rawPassword.isEmpty()) { + throw new MateClawException("err.approval.password_required", 403, + "this scope requires password re-confirmation"); + } + authService.verifyCurrentUserPassword(actorId, rawPassword); + } + + // ─── Validation ───────────────────────────────────────────────────── + + private static void validate(CreateGrantRequest body) { + if (body.scopeType == null || body.scopeType.isEmpty()) { + throw new MateClawException("err.approval.scope_type_required", 400, "scope_type is required"); + } + if (body.scopeId == null || body.scopeId.isEmpty()) { + throw new MateClawException("err.approval.scope_id_required", 400, "scope_id is required"); + } + if (body.maxSeverity == null + || !(body.maxSeverity.equals("LOW") || body.maxSeverity.equals("MEDIUM") || body.maxSeverity.equals("HIGH"))) { + // CRITICAL is explicitly rejected so it can never be auto-approvable; the resolver + // enforces the same gate at runtime as a defense in depth. + throw new MateClawException("err.approval.invalid_severity", 400, + "max_severity must be LOW | MEDIUM | HIGH (CRITICAL is not auto-approvable)"); + } + if (body.grantKind == null + || !(body.grantKind.equals("ALWAYS") + || body.grantKind.equals("UNTIL_TIMESTAMP") + || body.grantKind.equals("UNTIL_CONVERSATION_END"))) { + throw new MateClawException("err.approval.invalid_grant_kind", 400, + "grant_kind must be ALWAYS | UNTIL_TIMESTAMP | UNTIL_CONVERSATION_END"); + } + if ("UNTIL_TIMESTAMP".equals(body.grantKind) && body.expireAt == null) { + throw new MateClawException("err.approval.expire_at_required", 400, + "expire_at is required when grant_kind = UNTIL_TIMESTAMP"); + } + if ("UNTIL_CONVERSATION_END".equals(body.grantKind) + && !ApprovalGrant.ScopeType.CONVERSATION.equals(body.scopeType)) { + throw new MateClawException("err.approval.kind_scope_mismatch", 400, + "UNTIL_CONVERSATION_END requires scope_type = CONVERSATION"); + } + } + + // ─── Helpers ──────────────────────────────────────────────────────── + + private Long resolveUserId(Authentication auth) { + if (auth == null || auth.getName() == null) { + throw new MateClawException("err.auth.unauthenticated", 401, "未登录"); + } + UserEntity user = authService.findByUsername(auth.getName()); + if (user == null) { + throw new MateClawException("err.auth.user_not_found", 404, "用户不存在"); + } + return user.getId(); + } + + private static String emptyToNull(String s) { + return s == null || s.isEmpty() ? null : s; + } + + // ─── DTO ──────────────────────────────────────────────────────────── + + /** + * Request body for {@link #create}. Snowflake ids ({@code scopeId}) are + * received as strings to preserve precision through JS; the global Jackson + * coercion accepts numeric JSON too, so existing tools that send numbers + * still work. + */ + public static class CreateGrantRequest { + public String scopeType; + public String scopeId; + public String toolName; + public String ruleId; + public String maxSeverity; + public String grantKind; + public LocalDateTime expireAt; + public String note; + /** Required only for {@code WORKSPACE + tool_name=null} and {@code AGENT + tool_name=null}. */ + public String password; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/auth/service/AuthService.java b/mateclaw-server/src/main/java/vip/mate/auth/service/AuthService.java index 465d8e9c..6e412854 100644 --- a/mateclaw-server/src/main/java/vip/mate/auth/service/AuthService.java +++ b/mateclaw-server/src/main/java/vip/mate/auth/service/AuthService.java @@ -109,15 +109,33 @@ public class AuthService { * 修改密码 */ public void changePassword(Long userId, String oldPassword, String newPassword) { + verifyCurrentUserPassword(userId, oldPassword); + UserEntity user = userMapper.selectById(userId); + user.setPassword(passwordEncoder.encode(newPassword)); + userMapper.updateById(user); + } + + /** + * Step-up authentication: confirms that {@code rawPassword} matches the + * user's currently stored password without changing anything. + *

+ * Used by sensitive operations that require re-confirmation of identity + * (e.g. creating a workspace-wide all-tool auto-approve grant). Throws + * the same {@link MateClawException} keys as {@link #changePassword} so + * the user-facing error message stays consistent. + * + * @throws MateClawException {@code err.auth.user_not_found} when the user + * doesn't exist, or {@code err.auth.wrong_password} when the + * password doesn't match. + */ + public void verifyCurrentUserPassword(Long userId, String rawPassword) { UserEntity user = userMapper.selectById(userId); if (user == null) { throw new MateClawException("err.auth.user_not_found", "用户不存在"); } - if (!passwordEncoder.matches(oldPassword, user.getPassword())) { + if (rawPassword == null || !passwordEncoder.matches(rawPassword, user.getPassword())) { throw new MateClawException("err.auth.wrong_password", "原密码错误"); } - user.setPassword(passwordEncoder.encode(newPassword)); - userMapper.updateById(user); } /** diff --git a/mateclaw-server/src/test/java/vip/mate/approval/grant/controller/ApprovalGrantControllerTest.java b/mateclaw-server/src/test/java/vip/mate/approval/grant/controller/ApprovalGrantControllerTest.java new file mode 100644 index 00000000..36520f53 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/approval/grant/controller/ApprovalGrantControllerTest.java @@ -0,0 +1,323 @@ +package vip.mate.approval.grant.controller; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import vip.mate.approval.grant.entity.ApprovalGrant; +import vip.mate.approval.grant.repository.ApprovalGrantMapper; +import vip.mate.approval.grant.repository.ApprovalResolutionLogMapper; +import vip.mate.approval.grant.service.ApprovalGrantService; +import vip.mate.auth.model.UserEntity; +import vip.mate.auth.service.AuthService; +import vip.mate.exception.MateClawException; +import vip.mate.workspace.core.service.WorkspaceService; + +import java.time.LocalDateTime; +import java.util.List; + +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.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Plain controller tests for {@link ApprovalGrantController} — matches the + * mateclaw house style (see {@code WikiHotCacheControllerTest}, + * {@code WorkflowControllerTest}): no MockMvc, no Spring boot context, just + * direct method calls with mocked dependencies. + *

+ * The {@code @RequireWorkspaceRole("member")} HTTP gate is enforced by the + * shared {@code WorkspaceAccessInterceptor} and is exercised by its own tests; + * here we cover the in-method §2.4.5 6-cell matrix and the password second + * factor. + */ +@ExtendWith(MockitoExtension.class) +class ApprovalGrantControllerTest { + + private static final long WORKSPACE_ID = 100L; + private static final long MEMBER_ID = 1001L; + private static final long ADMIN_ID = 2002L; + + @Mock ApprovalGrantService grantService; + @Mock ApprovalGrantMapper grantMapper; + @Mock ApprovalResolutionLogMapper resolutionMapper; + @Mock AuthService authService; + @Mock WorkspaceService workspaceService; + + @InjectMocks + ApprovalGrantController controller; + + private Authentication memberAuth; + private Authentication adminAuth; + + @BeforeEach + void setUp() { + memberAuth = new UsernamePasswordAuthenticationToken("member-user", null); + adminAuth = new UsernamePasswordAuthenticationToken("admin-user", null); + + UserEntity member = new UserEntity(); + member.setId(MEMBER_ID); + member.setUsername("member-user"); + UserEntity admin = new UserEntity(); + admin.setId(ADMIN_ID); + admin.setUsername("admin-user"); + // Lenient: each test only uses one of the two users; strict mode would + // flag the unused one. Using lenient here keeps setUp shared. + lenient().when(authService.findByUsername("member-user")).thenReturn(member); + lenient().when(authService.findByUsername("admin-user")).thenReturn(admin); + } + + @Nested + class CreateAuthorizationMatrix { + + @Test + void conversation_scope_any_member_can_create() { + ApprovalGrantController.CreateGrantRequest body = baseBody("CONVERSATION", "conv-1", "read_file"); + + controller.create(body, WORKSPACE_ID, memberAuth); + + // No admin check, no password check; grant inserted. + verify(workspaceService, never()).requirePermission(anyLong(), anyLong(), anyString()); + verify(authService, never()).verifyCurrentUserPassword(anyLong(), anyString()); + verify(grantMapper).insert(any(ApprovalGrant.class)); + } + + @Test + void user_scope_only_targets_self() { + ApprovalGrantController.CreateGrantRequest body = baseBody("USER", "9999", "read_file"); + + assertThatThrownBy(() -> controller.create(body, WORKSPACE_ID, memberAuth)) + .isInstanceOf(MateClawException.class) + .hasMessageContaining("USER-scope"); + verify(grantMapper, never()).insert(any(ApprovalGrant.class)); + } + + @Test + void user_scope_self_succeeds() { + ApprovalGrantController.CreateGrantRequest body = baseBody("USER", + String.valueOf(MEMBER_ID), "read_file"); + + controller.create(body, WORKSPACE_ID, memberAuth); + + verify(grantMapper).insert(any(ApprovalGrant.class)); + } + + @Test + void agent_scope_explicit_tool_requires_admin() { + ApprovalGrantController.CreateGrantRequest body = baseBody("AGENT", "agent-1", "read_file"); + doThrow(new MateClawException("err.workspace.insufficient_permission", 403, "admin required")) + .when(workspaceService).requirePermission(WORKSPACE_ID, MEMBER_ID, "admin"); + + assertThatThrownBy(() -> controller.create(body, WORKSPACE_ID, memberAuth)) + .isInstanceOf(MateClawException.class); + } + + @Test + void agent_scope_null_tool_requires_admin_plus_password() { + ApprovalGrantController.CreateGrantRequest body = baseBody("AGENT", "agent-1", null); + // admin true; missing password → 403 + when(workspaceService.hasPermission(WORKSPACE_ID, ADMIN_ID, "admin")).thenReturn(true); + body.password = null; + + assertThatThrownBy(() -> controller.create(body, WORKSPACE_ID, adminAuth)) + .isInstanceOf(MateClawException.class) + .hasMessageContaining("password"); + } + + @Test + void agent_scope_null_tool_admin_with_password_passes() { + ApprovalGrantController.CreateGrantRequest body = baseBody("AGENT", "agent-1", null); + body.password = "correct-password"; + when(workspaceService.hasPermission(WORKSPACE_ID, ADMIN_ID, "admin")).thenReturn(true); + // verifyCurrentUserPassword passes silently when correct. + + controller.create(body, WORKSPACE_ID, adminAuth); + + verify(authService).verifyCurrentUserPassword(ADMIN_ID, "correct-password"); + verify(grantMapper).insert(any(ApprovalGrant.class)); + } + + @Test + void workspace_scope_explicit_tool_requires_admin_only() { + ApprovalGrantController.CreateGrantRequest body = baseBody("WORKSPACE", + String.valueOf(WORKSPACE_ID), "read_file"); + doThrow(new MateClawException("err.workspace.insufficient_permission", 403, "admin required")) + .when(workspaceService).requirePermission(WORKSPACE_ID, MEMBER_ID, "admin"); + + assertThatThrownBy(() -> controller.create(body, WORKSPACE_ID, memberAuth)) + .isInstanceOf(MateClawException.class); + } + + @Test + void workspace_scope_null_tool_requires_admin_plus_password_red_button() { + ApprovalGrantController.CreateGrantRequest body = baseBody("WORKSPACE", + String.valueOf(WORKSPACE_ID), null); + body.password = "correct-password"; + when(workspaceService.hasPermission(WORKSPACE_ID, ADMIN_ID, "admin")).thenReturn(true); + + controller.create(body, WORKSPACE_ID, adminAuth); + + verify(workspaceService).requirePermission(WORKSPACE_ID, ADMIN_ID, "admin"); + verify(authService).verifyCurrentUserPassword(ADMIN_ID, "correct-password"); + verify(grantMapper).insert(any(ApprovalGrant.class)); + } + + @Test + void critical_severity_is_rejected() { + ApprovalGrantController.CreateGrantRequest body = baseBody("CONVERSATION", "conv-1", "read_file"); + body.maxSeverity = "CRITICAL"; + + assertThatThrownBy(() -> controller.create(body, WORKSPACE_ID, memberAuth)) + .isInstanceOf(MateClawException.class) + .hasMessageContaining("CRITICAL is not auto-approvable"); + } + + @Test + void until_conversation_end_requires_conversation_scope() { + ApprovalGrantController.CreateGrantRequest body = baseBody("AGENT", "agent-1", "read_file"); + body.grantKind = "UNTIL_CONVERSATION_END"; + + assertThatThrownBy(() -> controller.create(body, WORKSPACE_ID, memberAuth)) + .isInstanceOf(MateClawException.class) + .hasMessageContaining("UNTIL_CONVERSATION_END"); + } + } + + @Nested + class ListRevoke { + + @Test + void list_mine_does_not_require_admin() { + when(grantMapper.selectList(any())).thenReturn(List.of()); + + controller.list(null, null, null, /*mine*/ true, WORKSPACE_ID, memberAuth); + + verify(workspaceService, never()).requirePermission(anyLong(), anyLong(), anyString()); + } + + @Test + void list_all_requires_admin() { + doThrow(new MateClawException("err.workspace.insufficient_permission", 403, "")) + .when(workspaceService).requirePermission(WORKSPACE_ID, MEMBER_ID, "admin"); + + assertThatThrownBy(() -> + controller.list(null, null, null, /*mine*/ false, WORKSPACE_ID, memberAuth)) + .isInstanceOf(MateClawException.class); + } + + @Test + void revoke_owner_succeeds_without_admin() { + ApprovalGrant g = newGrant(123L, MEMBER_ID); + when(grantMapper.selectById(123L)).thenReturn(g); + when(workspaceService.hasPermission(anyLong(), anyLong(), anyString())).thenReturn(false); + when(grantService.revoke(eq(123L), eq(MEMBER_ID))).thenReturn(true); + + controller.revoke(123L, WORKSPACE_ID, memberAuth); + + verify(grantService).revoke(123L, MEMBER_ID); + } + + @Test + void revoke_non_owner_non_admin_forbidden() { + ApprovalGrant g = newGrant(123L, /* grantedBy */ 5555L); + when(grantMapper.selectById(123L)).thenReturn(g); + when(workspaceService.hasPermission(WORKSPACE_ID, MEMBER_ID, "admin")).thenReturn(false); + + assertThatThrownBy(() -> controller.revoke(123L, WORKSPACE_ID, memberAuth)) + .isInstanceOf(MateClawException.class) + .hasMessageContaining("only the grant owner or a workspace admin"); + verify(grantService, never()).revoke(anyLong(), anyLong()); + } + + @Test + void revoke_admin_succeeds_for_other_users_grant() { + ApprovalGrant g = newGrant(123L, /* grantedBy */ 5555L); + when(grantMapper.selectById(123L)).thenReturn(g); + when(workspaceService.hasPermission(WORKSPACE_ID, ADMIN_ID, "admin")).thenReturn(true); + when(grantService.revoke(eq(123L), eq(ADMIN_ID))).thenReturn(true); + + controller.revoke(123L, WORKSPACE_ID, adminAuth); + + verify(grantService).revoke(123L, ADMIN_ID); + } + + @Test + void revoke_cross_workspace_returns_not_found() { + ApprovalGrant g = newGrant(123L, MEMBER_ID); + g.setWorkspaceId(999L); // different workspace + when(grantMapper.selectById(123L)).thenReturn(g); + + assertThatThrownBy(() -> controller.revoke(123L, WORKSPACE_ID, memberAuth)) + .isInstanceOf(MateClawException.class) + .hasMessageContaining("grant not found"); + } + } + + @Nested + class Resolutions { + + @Test + void grant_id_query_requires_admin() { + doThrow(new MateClawException("err.workspace.insufficient_permission", 403, "")) + .when(workspaceService).requirePermission(WORKSPACE_ID, MEMBER_ID, "admin"); + + assertThatThrownBy(() -> + controller.listResolutions(7777L, null, 100, WORKSPACE_ID, memberAuth)) + .isInstanceOf(MateClawException.class); + } + + @Test + void conversation_query_does_not_require_admin() { + when(resolutionMapper.selectList(any())).thenReturn(List.of()); + + controller.listResolutions(null, "conv-1", 100, WORKSPACE_ID, memberAuth); + + verify(workspaceService, never()).requirePermission(anyLong(), anyLong(), anyString()); + } + } + + // ─── Helpers ──────────────────────────────────────────────────────── + + private static ApprovalGrantController.CreateGrantRequest baseBody( + String scopeType, String scopeId, String toolName) { + ApprovalGrantController.CreateGrantRequest b = new ApprovalGrantController.CreateGrantRequest(); + b.scopeType = scopeType; + b.scopeId = scopeId; + b.toolName = toolName; + b.ruleId = null; + b.maxSeverity = "MEDIUM"; + b.grantKind = "ALWAYS"; + b.note = "test"; + return b; + } + + private static ApprovalGrant newGrant(long id, long grantedBy) { + ApprovalGrant g = new ApprovalGrant(); + g.setId(id); + g.setWorkspaceId(WORKSPACE_ID); + g.setScopeType("CONVERSATION"); + g.setScopeId("conv-1"); + g.setToolName("read_file"); + g.setMaxSeverity("MEDIUM"); + g.setGrantKind("ALWAYS"); + g.setGrantedBy(grantedBy); + g.setGrantedAt(LocalDateTime.now()); + g.setRevoked(0); + g.setDeleted(0); + return g; + } +}