From 992d9692d9dcb4857be40ce32e5f4b05124d6d15 Mon Sep 17 00:00:00 2001 From: yessjun Date: Mon, 10 Aug 2026 18:14:18 +0900 Subject: [PATCH 1/7] fix: keep the SSH slug out of restricted VM rows A restricted row nulled hostname but passed vm.getName(), and approval builds the VM with name == hostname, so the slug went out anyway. The row now carries the display name, falling back to the id when the VM has none; the name is never null because the console labels a row as displayName || name. --- contract/openapi.yaml | 1 + .../pickle/vm/dto/VmSummaryResponse.java | 27 ++++++++++++++++--- .../kr/ac/pusan/pickle/vm/VmDeletionTest.java | 7 ++++- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/contract/openapi.yaml b/contract/openapi.yaml index 176c9fb9..0b07521b 100644 --- a/contract/openapi.yaml +++ b/contract/openapi.yaml @@ -4772,6 +4772,7 @@ components: - "integer" - "null" name: + description: "SSH 슬러그. 접근 권한이 없으면 대신 표시 이름이 들어갑니다." type: "string" orgName: type: diff --git a/src/main/java/kr/ac/pusan/pickle/vm/dto/VmSummaryResponse.java b/src/main/java/kr/ac/pusan/pickle/vm/dto/VmSummaryResponse.java index 621c25e2..21821db6 100644 --- a/src/main/java/kr/ac/pusan/pickle/vm/dto/VmSummaryResponse.java +++ b/src/main/java/kr/ac/pusan/pickle/vm/dto/VmSummaryResponse.java @@ -18,9 +18,12 @@ * {@code ownerNames} says who to ask, and everything else the row would reveal * about the machine is omitted rather than blanked in the console. The redaction * happens here because a field the API sends has already left the building. + * {@code name} is the SSH slug on an open row, so a restricted row carries the + * display name there instead — see {@link #restricted}. */ public record VmSummaryResponse( Long id, + @Schema(description = "SSH 슬러그. 접근 권한이 없으면 대신 표시 이름이 들어갑니다.") String name, @Schema(description = "SSH 슬러그. 접근 권한이 없으면 생략됩니다.") @Nullable String hostname, @@ -57,12 +60,30 @@ public static VmSummaryResponse from(Vm vm, String workspaceName, String orgName false); } - /** Name, state and who to ask — nothing about the machine itself. */ + /** + * Name, state and who to ask — nothing about the machine itself. + * + *

{@code name} is the SSH slug: the same string one types to reach the + * machine, so it is exactly what somebody without a grant must not be handed. + * The display name takes its place, and {@code displayName} itself is left + * null so the row still renders as one label — a list that shows both would + * print the name twice. + */ public static VmSummaryResponse restricted(Vm vm, String workspaceName, String displayName, List ownerNames, boolean accessManageAllowed) { - return new VmSummaryResponse(vm.getId(), vm.getName(), null, vm.getStatus(), + return new VmSummaryResponse(vm.getId(), restrictedName(vm, displayName), null, vm.getStatus(), null, null, null, vm.getWorkspaceId(), workspaceName, null, - displayName, null, null, null, + null, null, null, null, null, null, vm.getCreatedAt(), true, ownerNames, accessManageAllowed); } + + /** + * What a restricted row is called. The display name when the VM has one; + * otherwise its id, which the row already carries and which no slug can be + * confused with. Never null: the console picks the label as + * {@code displayName || name}, so a null name would leave the row nameless. + */ + private static String restrictedName(Vm vm, String displayName) { + return displayName != null && !displayName.isBlank() ? displayName : "VM #" + vm.getId(); + } } diff --git a/src/test/java/kr/ac/pusan/pickle/vm/VmDeletionTest.java b/src/test/java/kr/ac/pusan/pickle/vm/VmDeletionTest.java index 8075c67d..d6c81e28 100644 --- a/src/test/java/kr/ac/pusan/pickle/vm/VmDeletionTest.java +++ b/src/test/java/kr/ac/pusan/pickle/vm/VmDeletionTest.java @@ -329,7 +329,12 @@ void workspaceOwnerStandingCarriesDeletionButNothingInsideTheVm() throws Excepti .andExpect(jsonPath("$.content[?(@.id==" + vmId + ")].accessManageAllowed") .value(org.hamcrest.Matchers.contains(true))) .andExpect(jsonPath("$.content[?(@.id==" + vmId + ")].hostname") - .value(org.hamcrest.Matchers.contains((Object) null))); + .value(org.hamcrest.Matchers.contains((Object) null))) + // Blanking `hostname` alone still handed the slug over: `name` + // is the same string on an open row. This VM has no display + // name, so the restricted row falls back to its id. + .andExpect(jsonPath("$.content[?(@.id==" + vmId + ")].name") + .value(org.hamcrest.Matchers.contains("VM #" + vmId))); // And the recovery path itself is open: they may read the access list // and put someone on it, which is the whole point of keeping deletion From e9f5ae50b596e2e6bb7b955452e3a17413cb6176 Mon Sep 17 00:00:00 2001 From: yessjun Date: Mon, 10 Aug 2026 18:14:26 +0900 Subject: [PATCH 2/7] fix: refuse a non-member request with a membership error The rule this 403 described was deleted: any member may ask, so the throw now fires only for someone outside the workspace. It said the caller lacked a rung and named VMs on a path every resource type travels. New code WORKSPACE_MEMBERSHIP_REQUIRED. --- .../ac/pusan/pickle/common/error/ErrorCodes.java | 13 +++++++++++++ .../kr/ac/pusan/pickle/request/RequestService.java | 14 ++++++++++---- .../kr/ac/pusan/pickle/request/RequestTest.java | 8 ++++++-- 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/main/java/kr/ac/pusan/pickle/common/error/ErrorCodes.java b/src/main/java/kr/ac/pusan/pickle/common/error/ErrorCodes.java index 9bdab81e..18405bc6 100644 --- a/src/main/java/kr/ac/pusan/pickle/common/error/ErrorCodes.java +++ b/src/main/java/kr/ac/pusan/pickle/common/error/ErrorCodes.java @@ -37,9 +37,22 @@ public final class ErrorCodes { public static final String WORKSPACE_MEMBER_ALREADY_EXISTS = "WORKSPACE_MEMBER_ALREADY_EXISTS"; public static final String WORKSPACE_SOLE_OWNER_REMOVAL = "WORKSPACE_SOLE_OWNER_REMOVAL"; public static final String WORKSPACE_ROLE_INSUFFICIENT = "WORKSPACE_ROLE_INSUFFICIENT"; + /** + * The caller is not a member of the workspace at all, on a path where every + * member is allowed. Distinct from {@link #WORKSPACE_ROLE_INSUFFICIENT} + * because the remedy is different: being added to the workspace, not being + * moved up a rung. + */ + public static final String WORKSPACE_MEMBERSHIP_REQUIRED = "WORKSPACE_MEMBERSHIP_REQUIRED"; // Workspace deletion (contract v0.9.0). public static final String WORKSPACE_HAS_ACTIVE_VMS = "WORKSPACE_HAS_ACTIVE_VMS"; public static final String WORKSPACE_PERSONAL_UNDELETABLE = "WORKSPACE_PERSONAL_UNDELETABLE"; + /** + * The workspace the operation would act in has been soft-deleted. Raised on + * approval, where the resource would otherwise be created inside a workspace + * that no longer exists. + */ + public static final String WORKSPACE_DELETED = "WORKSPACE_DELETED"; public static final String ORG_SLUG_DUPLICATE = "ORG_SLUG_DUPLICATE"; public static final String REQUEST_ALREADY_DECIDED = "REQUEST_ALREADY_DECIDED"; public static final String REQUEST_REQUESTER_INELIGIBLE = "REQUEST_REQUESTER_INELIGIBLE"; diff --git a/src/main/java/kr/ac/pusan/pickle/request/RequestService.java b/src/main/java/kr/ac/pusan/pickle/request/RequestService.java index 3875d4eb..e67dcfe4 100644 --- a/src/main/java/kr/ac/pusan/pickle/request/RequestService.java +++ b/src/main/java/kr/ac/pusan/pickle/request/RequestService.java @@ -87,7 +87,7 @@ public RequestDetailResponse create(AuthenticatedUser actor, CreateRequestReques // reaching VMs, which is now the access list's business, and asking is // not the step that costs anything — approval is. workspaceMemberRepository.findByWorkspaceIdAndUserId(workspace.getId(), actor.id()) - .orElseThrow(RequestService::requestRoleInsufficient); + .orElseThrow(RequestService::notWorkspaceMember); Org org = orgRepository.findById(form.orgId()) .orElseThrow(() -> notFound("해당 기관이 존재하지 않습니다.")); @@ -214,8 +214,14 @@ private static ApiException notFound(String detail) { "리소스를 찾을 수 없습니다", detail); } - private static ApiException requestRoleInsufficient() { - return new ApiException(HttpStatus.FORBIDDEN, ErrorCodes.WORKSPACE_ROLE_INSUFFICIENT, - "VM을 신청할 권한이 없습니다", "워크스페이스 소유자(OWNER) 또는 편집자(EDITOR)만 VM을 신청할 수 있습니다."); + /** + * The only standing this path still asks for is membership, and it is not + * about VMs: any type of request travels through here, and what the refusal + * has to tell the caller is that they are outside the workspace. + */ + private static ApiException notWorkspaceMember() { + return new ApiException(HttpStatus.FORBIDDEN, ErrorCodes.WORKSPACE_MEMBERSHIP_REQUIRED, + "워크스페이스 구성원이 아닙니다", + "이 워크스페이스의 구성원만 신청할 수 있습니다. 워크스페이스 소유자에게 구성원 추가를 요청해 주세요."); } } diff --git a/src/test/java/kr/ac/pusan/pickle/request/RequestTest.java b/src/test/java/kr/ac/pusan/pickle/request/RequestTest.java index ba3993fb..144528a0 100644 --- a/src/test/java/kr/ac/pusan/pickle/request/RequestTest.java +++ b/src/test/java/kr/ac/pusan/pickle/request/RequestTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.hamcrest.Matchers.containsString; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -146,10 +147,13 @@ void createValidatesRoleImageSpecAndDomains() throws Exception { .andExpect(status().isCreated()) .andExpect(jsonPath("$.requesterId").value(member.getId())); - // a non-member still cannot submit → 403 WORKSPACE_ROLE_INSUFFICIENT + // a non-member still cannot submit, and the refusal names the remedy: + // joining the workspace, not a rung they are missing postJson("/api/v1/requests", outsiderToken, validBody(workspaceId)) .andExpect(status().isForbidden()) - .andExpect(jsonPath("$.code").value("WORKSPACE_ROLE_INSUFFICIENT")); + .andExpect(jsonPath("$.code").value("WORKSPACE_MEMBERSHIP_REQUIRED")) + .andExpect(jsonPath("$.detail").value( + containsString("구성원"))); // unknown workspace / org / image → 404 postJson("/api/v1/requests", requesterToken, with(validBody(workspaceId), "workspaceId", 999_999)) From 7ef9141b75ac7face0fea25fc2d18afea5271044 Mon Sep 17 00:00:00 2001 From: yessjun Date: Mon, 10 Aug 2026 18:14:32 +0900 Subject: [PATCH 3/7] fix: render request notifications per resource type The submitted/approved/rejected notices are fired from the generic request and approval paths but were written as VM prose, and approval passed the resource name under a key called hostname. The type already travels in the args; the word now comes from it. VM's rendered text is unchanged. --- .../ac/pusan/pickle/access/ResourceType.java | 17 ++++++- .../pusan/pickle/admin/ApprovalService.java | 19 +++++++- .../notification/NotificationComposer.java | 47 ++++++++++++++----- 3 files changed, 68 insertions(+), 15 deletions(-) diff --git a/src/main/java/kr/ac/pusan/pickle/access/ResourceType.java b/src/main/java/kr/ac/pusan/pickle/access/ResourceType.java index 4ea6ae62..31148629 100644 --- a/src/main/java/kr/ac/pusan/pickle/access/ResourceType.java +++ b/src/main/java/kr/ac/pusan/pickle/access/ResourceType.java @@ -9,5 +9,20 @@ * in the authorization design. */ public enum ResourceType { - VM + VM("VM"); + + private final String label; + + ResourceType(String label) { + this.label = label; + } + + /** + * What user-facing text calls this kind of thing. Notifications about the + * request flow are shared by every type, so the word has to come from the + * type rather than from the sentence it appears in. + */ + public String label() { + return label; + } } diff --git a/src/main/java/kr/ac/pusan/pickle/admin/ApprovalService.java b/src/main/java/kr/ac/pusan/pickle/admin/ApprovalService.java index 3dfec8ca..9f3f0fbf 100644 --- a/src/main/java/kr/ac/pusan/pickle/admin/ApprovalService.java +++ b/src/main/java/kr/ac/pusan/pickle/admin/ApprovalService.java @@ -19,6 +19,7 @@ import kr.ac.pusan.pickle.common.text.Texts; import kr.ac.pusan.pickle.common.web.PageResponse; import kr.ac.pusan.pickle.workspace.WorkspaceMemberRepository; +import kr.ac.pusan.pickle.workspace.WorkspaceRepository; import kr.ac.pusan.pickle.notification.NotificationEvent; import kr.ac.pusan.pickle.notification.NotificationService; import kr.ac.pusan.pickle.security.AuthenticatedUser; @@ -59,6 +60,7 @@ public class ApprovalService { private final RequestAssembler assembler; private final Map handlers; private final WorkspaceMemberRepository workspaceMemberRepository; + private final WorkspaceRepository workspaceRepository; private final ResourceAccessGrantRepository grantRepository; private final UserRepository userRepository; private final AuditService auditService; @@ -67,6 +69,7 @@ public class ApprovalService { public ApprovalService(RequestRepository requestRepository, RequestReviewRepository reviewRepository, RequestAssembler assembler, List handlers, WorkspaceMemberRepository workspaceMemberRepository, + WorkspaceRepository workspaceRepository, ResourceAccessGrantRepository grantRepository, UserRepository userRepository, AuditService auditService, NotificationService notificationService) { this.requestRepository = requestRepository; @@ -75,6 +78,7 @@ public ApprovalService(RequestRepository requestRepository, RequestReviewReposit this.handlers = handlers.stream() .collect(Collectors.toMap(RequestTypeHandler::type, Function.identity())); this.workspaceMemberRepository = workspaceMemberRepository; + this.workspaceRepository = workspaceRepository; this.grantRepository = grantRepository; this.userRepository = userRepository; this.auditService = auditService; @@ -116,6 +120,16 @@ public RequestDetailResponse approve(AuthenticatedUser actor, long requestId, ApproveRequestRequest form, String ip) { Request request = findScopedWithLock(actor, requestId); requireSubmitted(request); + // Approval creates a resource inside the workspace, so the workspace has + // to still be there. Deleting a workspace cancels its in-flight requests, + // but a request submitted concurrently with that delete commits after the + // sweep has already run and stays SUBMITTED — this is what stops it from + // being approved into a workspace nobody can reach. + if (workspaceRepository.findByIdAndDeletedAtIsNull(request.getWorkspaceId()).isEmpty()) { + throw new ApiException(HttpStatus.CONFLICT, ErrorCodes.WORKSPACE_DELETED, + "워크스페이스가 삭제되었습니다", + "삭제된 워크스페이스에는 리소스를 만들 수 없습니다. 이 신청은 반려해 주세요."); + } RequestTypeHandler handler = handlerFor(request); List errors = new ArrayList<>(); @@ -167,7 +181,7 @@ public void afterCommit() { Map notifyArgs = new LinkedHashMap<>(); notifyArgs.put("requestId", request.getId()); notifyArgs.put("type", request.getResourceType().name()); - notifyArgs.put("hostname", created.resourceName()); + notifyArgs.put("resourceName", created.resourceName()); String reviewComment = Texts.blankToNull(form.comment()); if (reviewComment != null) { notifyArgs.put("comment", reviewComment); @@ -187,7 +201,8 @@ public RequestDetailResponse reject(AuthenticatedUser actor, long requestId, auditService.recordAfterCommit(actor.id(), actor.role().name(), AuditService.REQUEST_REJECT, "request", request.getId(), Map.of("workspaceId", request.getWorkspaceId()), ip); notificationService.publish(request.getRequesterId(), NotificationEvent.REQUEST_REJECTED, - Map.of("requestId", request.getId(), "comment", form.comment().strip()), null); + Map.of("requestId", request.getId(), "comment", form.comment().strip(), + "type", request.getResourceType().name()), null); return assembler.toDetail(request); } diff --git a/src/main/java/kr/ac/pusan/pickle/notification/NotificationComposer.java b/src/main/java/kr/ac/pusan/pickle/notification/NotificationComposer.java index e0f305ac..5ef2e10c 100644 --- a/src/main/java/kr/ac/pusan/pickle/notification/NotificationComposer.java +++ b/src/main/java/kr/ac/pusan/pickle/notification/NotificationComposer.java @@ -6,6 +6,7 @@ import java.time.format.DateTimeFormatter; import java.util.LinkedHashMap; import java.util.Map; +import kr.ac.pusan.pickle.access.ResourceType; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; @@ -48,19 +49,20 @@ public NotificationComposer( public Composed compose(NotificationEvent event, Map args) { return switch (event) { case REQUEST_SUBMITTED -> requestSubmitted(event, args); - case REQUEST_APPROVED -> new Composed(event.id(), "VM 신청 승인", + case REQUEST_APPROVED -> new Composed(event.id(), resourceLabel(args) + " 신청 승인", """ - VM 신청이 승인되었습니다. VM '%s' 생성이 시작됩니다. - 생성이 완료되면 다시 알려드립니다.%s""".formatted(str(args, "hostname"), + %s 신청이 승인되었습니다. %s '%s' 생성이 시작됩니다. + 생성이 완료되면 다시 알려드립니다.%s""".formatted(resourceLabel(args), + resourceLabel(args), str(args, "resourceName"), args.get("comment") != null ? "\n\n- 검토 의견: " + str(args, "comment") : ""), "/console/requests/" + args.get("requestId"), event.defaultImportance(), - payload(args, "requestId", "hostname")); - case REQUEST_REJECTED -> new Composed(event.id(), "VM 신청 반려", + payload(args, "requestId", "resourceName")); + case REQUEST_REJECTED -> new Composed(event.id(), resourceLabel(args) + " 신청 반려", """ - VM 신청이 반려되었습니다. + %s 신청이 반려되었습니다. - - 반려 사유: %s""".formatted(str(args, "comment")), + - 반려 사유: %s""".formatted(resourceLabel(args), str(args, "comment")), "/console/requests/" + args.get("requestId"), event.defaultImportance(), payload(args, "requestId")); case VM_CREATE_DONE -> new Composed(event.id(), @@ -363,16 +365,17 @@ public Composed compose(NotificationEvent event, Map args) { private Composed requestSubmitted(NotificationEvent event, Map args) { boolean admin = Boolean.TRUE.equals(args.get("admin")); - String title = admin ? "새 VM 신청 접수" : "VM 신청 접수"; + String label = resourceLabel(args); + String title = admin ? "새 " + label + " 신청 접수" : label + " 신청 접수"; String body = admin ? """ - 워크스페이스 '%s'에서 새 VM 신청이 접수되었습니다. 검토해 주세요. + 워크스페이스 '%s'에서 새 %s 신청이 접수되었습니다. 검토해 주세요. - - 신청 목적: %s""".formatted(str(args, "workspaceName"), str(args, "purpose")) + - 신청 목적: %s""".formatted(str(args, "workspaceName"), label, str(args, "purpose")) : """ - 워크스페이스 '%s'의 VM 신청이 접수되었습니다. 관리자 검토 후 결과를 알려드립니다. + 워크스페이스 '%s'의 %s 신청이 접수되었습니다. 관리자 검토 후 결과를 알려드립니다. - - 신청 목적: %s""".formatted(str(args, "workspaceName"), str(args, "purpose")); + - 신청 목적: %s""".formatted(str(args, "workspaceName"), label, str(args, "purpose")); String link = (admin ? "/admin/requests/" : "/console/requests/") + args.get("requestId"); return new Composed(event.id(), title, body, link, event.defaultImportance(), payload(args, "requestId", "workspaceName")); @@ -393,6 +396,26 @@ private Composed expiryNotice(Map args) { payload(args, "vmId", "vmName", "endDate")); } + /** + * The word the request-flow notices use for the thing being asked for. The + * request events are shared by every resource type, so the type travels in + * the payload and the sentence reads it; a payload without one (or naming a + * type this build does not know) falls back to the generic word rather than + * asserting it is a VM. + */ + private static String resourceLabel(Map args) { + Object type = args.get("type"); + if (type == null) { + return "리소스"; + } + for (ResourceType candidate : ResourceType.values()) { + if (candidate.name().equals(String.valueOf(type))) { + return candidate.label(); + } + } + return "리소스"; + } + private static String str(Map args, String key) { Object value = args.get(key); return value != null ? String.valueOf(value) : ""; From 1e82ab84e68890c3273d51b20688e230e8d400c8 Mon Sep 17 00:00:00 2001 From: yessjun Date: Mon, 10 Aug 2026 18:14:44 +0900 Subject: [PATCH 4/7] refactor: make the notification audience rule type-generic vmRecipients was typed on Vm and hardcoded ResourceType.VM, so a second type would have needed a copy of a rule that does not depend on the type. The VM helpers now pass their own type through. --- .../notification/NotificationService.java | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/src/main/java/kr/ac/pusan/pickle/notification/NotificationService.java b/src/main/java/kr/ac/pusan/pickle/notification/NotificationService.java index c942a901..a4be1b40 100644 --- a/src/main/java/kr/ac/pusan/pickle/notification/NotificationService.java +++ b/src/main/java/kr/ac/pusan/pickle/notification/NotificationService.java @@ -121,12 +121,14 @@ public List workspaceOwnerIds(long workspaceId) { * own list; right after the changeover both answers name the same people. */ public List vmResponsibleIds(Vm vm) { - return vmRecipients(vm, List.of(ResourceRole.OWNER, ResourceRole.EDITOR)); + return resourceRecipients(ResourceType.VM, vm.getId(), vm.getWorkspaceId(), + List.of(ResourceRole.OWNER, ResourceRole.EDITOR)); } /** Narrower audience: only those the list makes an owner of the VM. */ public List vmOwnerIds(Vm vm) { - return vmRecipients(vm, List.of(ResourceRole.OWNER)); + return resourceRecipients(ResourceType.VM, vm.getId(), vm.getWorkspaceId(), + List.of(ResourceRole.OWNER)); } /** @@ -135,17 +137,26 @@ public List vmOwnerIds(Vm vm) { * deletion. */ public List vmAudienceIds(Vm vm) { - return vmRecipients(vm, List.of(ResourceRole.values())); + return resourceRecipients(ResourceType.VM, vm.getId(), vm.getWorkspaceId(), + List.of(ResourceRole.values())); } - private List vmRecipients(Vm vm, Collection roles) { + /** + * Who hears about one resource, whatever kind it is: the people its access + * list names at the given rungs, plus the owners of the workspace that owns + * it. The rule is the same for every type — a grant is a grant — so a second + * kind of resource answers this question by calling here with its own type + * rather than by growing a parallel audience rule beside this one. + */ + public List resourceRecipients(ResourceType type, long resourceId, long workspaceId, + Collection roles) { Set ids = grantRepository - .findByResourceTypeAndResourceIdAndGranteeTypeAndRoleIn(ResourceType.VM, - vm.getId(), AccessGranteeType.USER, roles) + .findByResourceTypeAndResourceIdAndGranteeTypeAndRoleIn(type, + resourceId, AccessGranteeType.USER, roles) .stream() .map(ResourceAccessGrant::getUserId) .collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)); - ids.addAll(workspaceMemberRepository.findByWorkspaceIdOrderByIdAsc(vm.getWorkspaceId()).stream() + ids.addAll(workspaceMemberRepository.findByWorkspaceIdOrderByIdAsc(workspaceId).stream() .filter(m -> m.getRole() == WorkspaceMemberRole.OWNER) .map(WorkspaceMember::getUserId) .toList()); From 9a258dd1e70d9347fe7369795357f0a2199de4bf Mon Sep 17 00:00:00 2001 From: yessjun Date: Mon, 10 Aug 2026 18:14:44 +0900 Subject: [PATCH 5/7] fix: count every resource type when deleting a workspace Deletion counted VMs directly, so a workspace holding live resources of a later type would have deleted cleanly. Each type answers through its adapter, the way grant revocation already asks. The count also moved after the in-flight requests are locked: taken first it reads zero while an approval holds those locks uncommitted, and the workspace is then soft-deleted around the VM that approval creates. --- .../pickle/resource/ResourceTypeAdapter.java | 11 ++++++ .../pickle/resource/VmResourceAdapter.java | 7 ++++ .../pickle/workspace/WorkspaceService.java | 38 ++++++++++--------- .../pickle/workspace/WorkspaceDeleteTest.java | 9 ++++- 4 files changed, 47 insertions(+), 18 deletions(-) diff --git a/src/main/java/kr/ac/pusan/pickle/resource/ResourceTypeAdapter.java b/src/main/java/kr/ac/pusan/pickle/resource/ResourceTypeAdapter.java index 13041558..5dbd9d90 100644 --- a/src/main/java/kr/ac/pusan/pickle/resource/ResourceTypeAdapter.java +++ b/src/main/java/kr/ac/pusan/pickle/resource/ResourceTypeAdapter.java @@ -33,6 +33,17 @@ public interface ResourceTypeAdapter { */ List idsOwnedByWorkspace(long workspaceId); + /** + * How many of this type the workspace still holds that are not yet fully + * destroyed — what stands between the workspace and its own deletion. + * + *

A resource on its way out counts: it still holds the platform's + * resources until its destruction completes, and the workspace it belongs to + * has to outlive it. Only the rows whose history is all that is left of them + * are excluded. + */ + long countLiveInWorkspace(long workspaceId); + /** * This type's contribution to the inventory the requester may see, with the * same visibility rules the type's own list endpoint applies: a row the diff --git a/src/main/java/kr/ac/pusan/pickle/resource/VmResourceAdapter.java b/src/main/java/kr/ac/pusan/pickle/resource/VmResourceAdapter.java index 35697896..cd017c3c 100644 --- a/src/main/java/kr/ac/pusan/pickle/resource/VmResourceAdapter.java +++ b/src/main/java/kr/ac/pusan/pickle/resource/VmResourceAdapter.java @@ -6,6 +6,7 @@ import kr.ac.pusan.pickle.security.AuthenticatedUser; import kr.ac.pusan.pickle.vm.VmQueryService; import kr.ac.pusan.pickle.vm.VmRepository; +import kr.ac.pusan.pickle.vm.VmStatus; import kr.ac.pusan.pickle.vm.dto.VmSummaryResponse; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; @@ -34,6 +35,12 @@ public List idsOwnedByWorkspace(long workspaceId) { return vmRepository.findIdsByWorkspaceIdIn(List.of(workspaceId)); } + @Override + public long countLiveInWorkspace(long workspaceId) { + // DELETING counts: the VM is still there until its destruction finishes. + return vmRepository.countActiveByWorkspaceId(workspaceId, VmStatus.DELETED); + } + @Override public Page page(AuthenticatedUser actor, Long workspaceId, Pageable pageable) { diff --git a/src/main/java/kr/ac/pusan/pickle/workspace/WorkspaceService.java b/src/main/java/kr/ac/pusan/pickle/workspace/WorkspaceService.java index f2f42d07..1d82dc07 100644 --- a/src/main/java/kr/ac/pusan/pickle/workspace/WorkspaceService.java +++ b/src/main/java/kr/ac/pusan/pickle/workspace/WorkspaceService.java @@ -25,8 +25,6 @@ import kr.ac.pusan.pickle.user.User; import kr.ac.pusan.pickle.user.UserRepository; import kr.ac.pusan.pickle.user.UserStatus; -import kr.ac.pusan.pickle.vm.VmRepository; -import kr.ac.pusan.pickle.vm.VmStatus; import kr.ac.pusan.pickle.request.Request; import kr.ac.pusan.pickle.request.RequestRepository; import kr.ac.pusan.pickle.request.RequestStatus; @@ -49,21 +47,19 @@ public class WorkspaceService { private final WorkspaceMemberRepository workspaceMemberRepository; private final ResourceAccessGrantRepository grantRepository; private final UserRepository userRepository; - private final VmRepository vmRepository; private final RequestRepository requestRepository; private final AuditService auditService; private final NotificationService notificationService; private final List resourceAdapters; public WorkspaceService(WorkspaceRepository workspaceRepository, WorkspaceMemberRepository workspaceMemberRepository, - ResourceAccessGrantRepository grantRepository, UserRepository userRepository, VmRepository vmRepository, + ResourceAccessGrantRepository grantRepository, UserRepository userRepository, RequestRepository requestRepository, AuditService auditService, NotificationService notificationService, List resourceAdapters) { this.workspaceRepository = workspaceRepository; this.workspaceMemberRepository = workspaceMemberRepository; this.grantRepository = grantRepository; this.userRepository = userRepository; - this.vmRepository = vmRepository; this.requestRepository = requestRepository; this.auditService = auditService; this.notificationService = notificationService; @@ -256,11 +252,12 @@ private int revokeGrantsOnWorkspaceResources(long workspaceId, long userId) { /** * Soft-deletes a workspace (contract {@code deleteWorkspace}). OWNER only; * non-members are masked as 404 and members below OWNER get 403. PERSONAL - * workspaces are never deletable (409), and a workspace with any non-destroyed VM - * (DELETED excluded, DELETING counts as blocking — shared - * {@link VmRepository#countActiveByWorkspaceId}) is refused (409). The row is - * kept (VM/audit history) with {@code deleted_at} stamped; ACTIVE members - * are notified and the deletion is audited. + * workspaces are never deletable (409), and a workspace still holding any + * resource that is not fully destroyed is refused (409) — every type answers + * for itself through {@link ResourceTypeAdapter#countLiveInWorkspace}, so a + * type added later blocks deletion without this method being touched. The row + * is kept (resource/audit history) with {@code deleted_at} stamped; ACTIVE + * members are notified and the deletion is audited. */ @Transactional public void delete(AuthenticatedUser actor, long workspaceId, String ip) { @@ -277,13 +274,7 @@ public void delete(AuthenticatedUser actor, long workspaceId, String ip) { "워크스페이스를 삭제할 수 없습니다", "개인 워크스페이스는 삭제할 수 없습니다. 계정 탈퇴 시에만 함께 정리됩니다."); } - if (vmRepository.countActiveByWorkspaceId(workspaceId, VmStatus.DELETED) > 0) { - throw new ApiException(HttpStatus.CONFLICT, ErrorCodes.WORKSPACE_HAS_ACTIVE_VMS, - "워크스페이스를 삭제할 수 없습니다", - "워크스페이스에 삭제되지 않은 VM이 있습니다. VM 삭제(파기 완료) 후 다시 시도해 주세요."); - } - - // Cancel the workspace's in-flight (SUBMITTED) VM requests in this tx so an + // Cancel the workspace's in-flight (SUBMITTED) requests in this tx so an // approval racing the delete can't provision into a dead workspace. Each // request is locked and re-checked (same guard the cancel/approve paths // use) — a request the approver already decided is left alone, and its @@ -301,6 +292,19 @@ public void delete(AuthenticatedUser actor, long workspaceId, String ip) { Map.of("workspaceId", workspaceId, "reason", "workspace_deleted"), ip); } + // Counted after the request rows are locked, not before: an approval + // holding one of those locks has not committed its resource yet, so a + // count taken first reads zero and then waits for that very approval — + // and the workspace is soft-deleted around a live resource. Taking the + // locks first means an approval that won the race has committed by the + // time this count runs, and read-committed sees it. + if (resourceAdapters.stream() + .anyMatch(adapter -> adapter.countLiveInWorkspace(workspaceId) > 0)) { + throw new ApiException(HttpStatus.CONFLICT, ErrorCodes.WORKSPACE_HAS_ACTIVE_VMS, + "워크스페이스를 삭제할 수 없습니다", + "워크스페이스에 삭제되지 않은 리소스가 있습니다. 리소스 삭제(파기 완료) 후 다시 시도해 주세요."); + } + // Recipients are resolved before the soft-delete flips visibility; the // membership rows themselves are kept (only the workspace row is stamped). List recipients = notificationService.workspaceMemberIds(workspaceId); diff --git a/src/test/java/kr/ac/pusan/pickle/workspace/WorkspaceDeleteTest.java b/src/test/java/kr/ac/pusan/pickle/workspace/WorkspaceDeleteTest.java index d8c3f9c0..6b7be4de 100644 --- a/src/test/java/kr/ac/pusan/pickle/workspace/WorkspaceDeleteTest.java +++ b/src/test/java/kr/ac/pusan/pickle/workspace/WorkspaceDeleteTest.java @@ -95,12 +95,19 @@ void authorizationMatrixAndBlockers() throws Exception { .andExpect(status().isForbidden()) .andExpect(jsonPath("$.code").value("WORKSPACE_ROLE_INSUFFICIENT")); - // an active VM blocks deletion → 409 + // an active resource blocks deletion → 409 long vmId = insertVm(workspaceId, "RUNNING"); + // The delete cancels in-flight requests before it counts what the + // workspace holds — the order that keeps a racing approval from slipping + // a live VM in behind the count. A refused delete must therefore leave + // those requests exactly as it found them. + long pendingRequestId = insertSubmittedRequest(workspaceId); mockMvc.perform(delete("/api/v1/workspaces/" + workspaceId) .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isConflict()) .andExpect(jsonPath("$.code").value("WORKSPACE_HAS_ACTIVE_VMS")); + assertThat(jdbcTemplate.queryForObject("select status::text from requests where id = ?", + String.class, pendingRequestId)).isEqualTo("SUBMITTED"); // even a DELETING VM still blocks jdbcTemplate.update("update vms set status = 'DELETING' where id = ?", vmId); mockMvc.perform(delete("/api/v1/workspaces/" + workspaceId) From 6e9bd06bbc582f0f5e2ae8166bbc4f0613490257 Mon Sep 17 00:00:00 2001 From: yessjun Date: Mon, 10 Aug 2026 18:15:12 +0900 Subject: [PATCH 6/7] test: cover approval into a soft-deleted workspace A request submitted concurrently with a workspace delete commits after that delete's cancellation sweep has run, so it survives as SUBMITTED. Approving it is refused; rejecting it still works. --- .../ac/pusan/pickle/admin/ApprovalTest.java | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/test/java/kr/ac/pusan/pickle/admin/ApprovalTest.java b/src/test/java/kr/ac/pusan/pickle/admin/ApprovalTest.java index 90b5d558..2e808edb 100644 --- a/src/test/java/kr/ac/pusan/pickle/admin/ApprovalTest.java +++ b/src/test/java/kr/ac/pusan/pickle/admin/ApprovalTest.java @@ -610,6 +610,33 @@ void approvalContextShowsPanelsHeadroomAndGuidance() throws Exception { } } + /** + * Deleting a workspace cancels the requests it can see, but a request + * submitted concurrently with the delete commits after that sweep has run + * and stays SUBMITTED. Approving it would create a VM inside a workspace + * nobody can reach, so approval refuses; rejection is the way out, and it + * still works because it creates nothing. + */ + @Test + void approvalRefusesARequestWhoseWorkspaceIsGone() throws Exception { + long workspaceId = createTeam(userToken, "appr-deleted-ws"); + long requestId = submit(userToken, workspaceId); + long survivor = submit(userToken, workspaceId); + jdbcTemplate.update("update workspaces set deleted_at = now(), deleted_by = ? where id = ?", + regularUser.getId(), workspaceId); + + postJson("/api/v1/admin/requests/" + requestId + "/approve", orgAdminToken, approveBody()) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("WORKSPACE_DELETED")); + assertThat(vmRepository.findAll().stream() + .filter(vm -> vm.getRequestId() == requestId)).isEmpty(); + + postJson("/api/v1/admin/requests/" + survivor + "/reject", orgAdminToken, + Map.of("comment", "워크스페이스가 삭제되어 반려합니다.")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("REJECTED")); + } + private Map approveBody() { Map vm = new HashMap<>(); vm.put("grantedVcpu", 2); From ec2a200f6d4c01e7683878f448b6dd5c16eb366c Mon Sep 17 00:00:00 2001 From: yessjun Date: Mon, 10 Aug 2026 18:15:12 +0900 Subject: [PATCH 7/7] test: cover the resource inventory endpoint Nothing under src/test touched /api/v1/resources, so its permission row was decorative. Covers a member's own workspace rows, an empty page for a workspace filter outside the caller's memberships, the restricted row (asserted against the whole body: the slug appears nowhere) and the 401. --- .../pickle/resource/ResourceIndexTest.java | 262 ++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 src/test/java/kr/ac/pusan/pickle/resource/ResourceIndexTest.java diff --git a/src/test/java/kr/ac/pusan/pickle/resource/ResourceIndexTest.java b/src/test/java/kr/ac/pusan/pickle/resource/ResourceIndexTest.java new file mode 100644 index 00000000..1cdde658 --- /dev/null +++ b/src/test/java/kr/ac/pusan/pickle/resource/ResourceIndexTest.java @@ -0,0 +1,262 @@ +package kr.ac.pusan.pickle.resource; + +import static kr.ac.pusan.pickle.support.AccessGrantFixtures.grantVmToUser; +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.Instant; +import java.util.Map; +import java.util.UUID; +import kr.ac.pusan.pickle.security.JwtService; +import kr.ac.pusan.pickle.support.EmbeddedPostgresConfig; +import kr.ac.pusan.pickle.support.ReauthTestSupport; +import kr.ac.pusan.pickle.support.RequestFixtures; +import kr.ac.pusan.pickle.support.SeedFixtures; +import kr.ac.pusan.pickle.user.User; +import kr.ac.pusan.pickle.user.UserRepository; +import kr.ac.pusan.pickle.user.UserStatus; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import tools.jackson.databind.ObjectMapper; + +/** + * The type-agnostic inventory, {@code GET /resources} (contract tag + * {@code resources}). + * + *

The endpoint reuses the VM list rather than re-deriving visibility, so + * what these tests are really about is that the reuse holds: the inventory must + * not show a workspace the caller is outside of, and must not say more about a + * resource than the VM list would. The restricted row is the sharp end — the + * name it carries is a display label, never the SSH slug, which is the string + * one types to reach the machine. + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Import(EmbeddedPostgresConfig.class) +class ResourceIndexTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private JwtService jwtService; + + @Autowired + private UserRepository userRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + private User owner; + private User member; + private User outsider; + private String ownerToken; + private String memberToken; + private String outsiderToken; + private long orgId; + private long nodeId; + private long imageId; + private long workspaceId; + private String workspaceName; + + @BeforeEach + void setUp() throws Exception { + owner = ensureUser("resindex.owner@pusan.ac.kr", "인벤토리소유자"); + member = ensureUser("resindex.member@pusan.ac.kr", "인벤토리구성원"); + outsider = ensureUser("resindex.outsider@pusan.ac.kr", "인벤토리외부인"); + ownerToken = jwtService.createAccessToken(owner); + memberToken = jwtService.createAccessToken(member); + outsiderToken = jwtService.createAccessToken(outsider); + orgId = SeedFixtures.seedOrgId(jdbcTemplate); + nodeId = jdbcTemplate.queryForObject("select min(id) from nodes", Long.class); + imageId = jdbcTemplate.queryForObject("select min(id) from os_images", Long.class); + String slug = "resindex-" + UUID.randomUUID().toString().substring(0, 8); + workspaceName = "인벤토리 테스트 " + slug; + workspaceId = createTeam(slug, workspaceName); + addMember(workspaceId, member.getEmail()); + } + + @Test + void inventoryListsTheWorkspaceRowsAMemberMaySee() throws Exception { + long vmId = createVm(); + grantVmToUser(jdbcTemplate, vmId, member.getId(), "VIEWER"); + String hostname = hostnameOf(vmId); + + mockMvc.perform(get("/api/v1/resources?workspaceId=" + workspaceId) + .header("Authorization", "Bearer " + memberToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[?(@.id==" + vmId + ")].type") + .value(Matchers.contains("VM"))) + .andExpect(jsonPath("$.content[?(@.id==" + vmId + ")].name") + .value(Matchers.contains(hostname))) + .andExpect(jsonPath("$.content[?(@.id==" + vmId + ")].status") + .value(Matchers.contains("RUNNING"))) + .andExpect(jsonPath("$.content[?(@.id==" + vmId + ")].workspaceName") + .value(Matchers.contains(workspaceName))) + .andExpect(jsonPath("$.content[?(@.id==" + vmId + ")].accessLimited") + .value(Matchers.contains(false))); + + // The untyped listing answers the same while VM is the only type, and + // an explicit type filter narrows to it rather than changing the rows. + mockMvc.perform(get("/api/v1/resources?type=VM&workspaceId=" + workspaceId) + .header("Authorization", "Bearer " + memberToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[?(@.id==" + vmId + ")].name") + .value(Matchers.contains(hostname))); + } + + @Test + void aWorkspaceTheCallerIsOutsideOfIsAnEmptyPage() throws Exception { + createVm(); + + // No 403: the contract gives the listing no forbidden response, so a + // workspace filter outside the caller's memberships simply matches + // nothing — which also keeps the workspace's existence private. + mockMvc.perform(get("/api/v1/resources?workspaceId=" + workspaceId) + .header("Authorization", "Bearer " + outsiderToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.length()").value(0)) + .andExpect(jsonPath("$.totalElements").value(0)); + + // And an id no workspace has answers the same way. + mockMvc.perform(get("/api/v1/resources?workspaceId=999999") + .header("Authorization", "Bearer " + ownerToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalElements").value(0)); + } + + @Test + void aRowWithoutAGrantIsRestrictedAndCarriesNoSlug() throws Exception { + // The member is in the workspace but on nobody's access list, so they + // see the resource exists and no more. + long vmId = createVm(); + String hostname = hostnameOf(vmId); + + mockMvc.perform(get("/api/v1/resources?workspaceId=" + workspaceId) + .header("Authorization", "Bearer " + memberToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[?(@.id==" + vmId + ")].accessLimited") + .value(Matchers.contains(true))) + .andExpect(jsonPath("$.content[?(@.id==" + vmId + ")].ownerNames[0]") + .value(Matchers.contains(owner.getName()))) + // The whole point: the name a restricted row carries must not be + // the slug, which would hand over the SSH address of a machine + // this caller may not reach. With no display name set it falls + // back to the id. + .andExpect(jsonPath("$.content[?(@.id==" + vmId + ")].name") + .value(Matchers.contains("VM #" + vmId))); + // Asserted against the whole body, not one field: the slug must not + // appear anywhere in the response, whichever field might carry it. + String body = mockMvc.perform(get("/api/v1/resources?workspaceId=" + workspaceId) + .header("Authorization", "Bearer " + memberToken)) + .andReturn().getResponse().getContentAsString(); + assertThat(body).doesNotContain(hostname); + + // A display name is what the row shows when there is one — it is the + // label the workspace chose, and it is not an address. + setDisplayName(vmId, "연구용 서버"); + mockMvc.perform(get("/api/v1/resources?workspaceId=" + workspaceId) + .header("Authorization", "Bearer " + memberToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[?(@.id==" + vmId + ")].name") + .value(Matchers.contains("연구용 서버"))); + + // Granted, the same row opens and the slug comes back with it. + grantVmToUser(jdbcTemplate, vmId, member.getId(), "VIEWER"); + mockMvc.perform(get("/api/v1/resources?workspaceId=" + workspaceId) + .header("Authorization", "Bearer " + memberToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[?(@.id==" + vmId + ")].accessLimited") + .value(Matchers.contains(false))) + .andExpect(jsonPath("$.content[?(@.id==" + vmId + ")].name") + .value(Matchers.contains(hostname))) + .andExpect(jsonPath("$.content[?(@.id==" + vmId + ")].displayName") + .value(Matchers.contains("연구용 서버"))); + } + + @Test + void unauthenticatedCallIsRejected() throws Exception { + mockMvc.perform(get("/api/v1/resources")) + .andExpect(status().isUnauthorized()); + } + + // ── helpers ──────────────────────────────────────────────────────────── + + private long createVm() { + long requestId = RequestFixtures.insertVmRequest(jdbcTemplate, workspaceId, orgId, + owner.getId(), "인벤토리 테스트", imageId, 1, 1024, 10); + String hostname = "resindex-" + UUID.randomUUID().toString().substring(0, 12); + long vmId = jdbcTemplate.queryForObject(""" + insert into vms (node_id, workspace_id, org_id, request_id, name, hostname, + image_id, vcpu, memory_mb, disk_gb, proxmox_vmid, status) + values (?, ?, ?, ?, ?, ?, ?, 1, 1024, 10, + (select coalesce(max(proxmox_vmid), 100000) + 1 from vms), 'RUNNING') + returning id + """, Long.class, nodeId, workspaceId, orgId, requestId, hostname, hostname, + imageId); + // Approval is bypassed here, so the access list is written by hand: the + // requester owns it and nobody else is named. + grantVmToUser(jdbcTemplate, vmId, owner.getId(), "OWNER"); + return vmId; + } + + /** The SSH slug of a VM — what a restricted row must never contain. */ + private String hostnameOf(long vmId) { + return jdbcTemplate.queryForObject("select hostname from vms where id = ?", String.class, + vmId); + } + + private void setDisplayName(long vmId, String displayName) { + jdbcTemplate.update(""" + insert into vm_settings (vm_id, key, value, updated_at) + values (?, 'display_name', to_jsonb(?::text), now()) + """, vmId, displayName); + } + + private long createTeam(String slug, String name) throws Exception { + String body = mockMvc.perform(post("/api/v1/workspaces") + .header("Authorization", "Bearer " + ownerToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString( + Map.of("kind", "TEAM", "name", name)))) + .andExpect(status().isCreated()) + .andReturn().getResponse().getContentAsString(); + return objectMapper.readTree(body).get("id").asLong(); + } + + private void addMember(long workspaceId, String email) throws Exception { + mockMvc.perform(post("/api/v1/workspaces/" + workspaceId + "/members") + .header("Authorization", "Bearer " + ownerToken) + .header(ReauthTestSupport.HEADER, ReauthTestSupport.seededReauthFor( + jdbcTemplate, jwtService, ownerToken)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString( + Map.of("email", email, "role", "MEMBER")))) + .andExpect(status().isCreated()); + } + + private User ensureUser(String email, String name) { + return userRepository.findByEmail(email).orElseGet(() -> { + User user = new User(email, "{test-no-login}", name); + user.setStatus(UserStatus.ACTIVE); + user.setEmailVerifiedAt(Instant.now()); + return userRepository.save(user); + }); + } +}