diff --git a/contract/openapi.yaml b/contract/openapi.yaml index be2d96ef..90f6434b 100644 --- a/contract/openapi.yaml +++ b/contract/openapi.yaml @@ -24,8 +24,8 @@ components: type: "string" detail: {} id: - format: "int64" - type: "integer" + description: "활동 행 식별자. 목록 렌더링용 키이며 어떤 조회 파라미터도 아닙니다." + type: "string" ip: type: - "string" @@ -54,9 +54,9 @@ components: userId: description: "대상 사용자 id. granteeType이 USER일 때만 보내며, 소유 워크스페이스의 구성원이어야 합니\ 다." - format: "int64" + format: "uuid" type: - - "integer" + - "string" - "null" required: - "granteeType" @@ -89,12 +89,12 @@ components: - "string" - "null" id: - format: "int64" - type: "integer" + format: "uuid" + type: "string" orgId: - format: "int64" + format: "uuid" type: - - "integer" + - "string" - "null" ports: description: "사용할 포트 번호 목록" @@ -108,16 +108,16 @@ components: - "string" - "null" processedBy: - format: "int64" + format: "uuid" type: - - "integer" + - "string" - "null" purpose: description: "신청 목적" type: "string" requestedBy: - format: "int64" - type: "integer" + format: "uuid" + type: "string" requesterEmail: description: "신청자 이메일" type: @@ -128,8 +128,8 @@ components: description: "신청 상태 (REQUESTED = 신청, APPROVED = 관리자 승인, GRANTED = 교내 IP\ \ 연결 완료, REJECTED = 반려, REVOKED = 회수)" vmId: - format: "int64" - type: "integer" + format: "uuid" + type: "string" vmName: type: - "string" @@ -151,13 +151,13 @@ components: - "integer" - "null" domainId: - format: "int64" + format: "uuid" type: - - "integer" + - "string" - "null" id: - format: "int64" - type: "integer" + format: "uuid" + type: "string" kind: $ref: "#/components/schemas/CertificateKind" lastError: @@ -191,13 +191,13 @@ components: fqdn: type: "string" id: - format: "int64" - type: "integer" + format: "uuid" + type: "string" kind: $ref: "#/components/schemas/DomainKind" orgId: - format: "int64" - type: "integer" + format: "uuid" + type: "string" orgName: type: "string" releasedAt: @@ -231,13 +231,13 @@ components: - "string" - "null" vmId: - format: "int64" - type: "integer" + format: "uuid" + type: "string" vmName: type: "string" workspaceId: - format: "int64" - type: "integer" + format: "uuid" + type: "string" workspaceName: type: "string" required: @@ -268,8 +268,8 @@ components: event: type: "string" id: - format: "int64" - type: "integer" + format: "uuid" + type: "string" importance: $ref: "#/components/schemas/NotificationImportance" lastError: @@ -297,8 +297,8 @@ components: userEmail: type: "string" userId: - format: "int64" - type: "integer" + format: "uuid" + type: "string" required: - "attempts" - "body" @@ -317,16 +317,16 @@ components: displayName: type: "string" id: - format: "int64" - type: "integer" + format: "uuid" + type: "string" minDiskGb: format: "int32" type: "integer" name: type: "string" nodeId: - format: "int64" - type: "integer" + format: "uuid" + type: "string" notes: type: - "string" @@ -366,8 +366,10 @@ components: format: "date-time" type: "string" createdBy: - format: "int64" - type: "integer" + format: "uuid" + type: + - "string" + - "null" ctMax: description: "동시 연결 상한 오버라이드 (null = 에이전트 기본, 0 = 해제)" format: "int32" @@ -375,8 +377,8 @@ components: - "integer" - "null" id: - format: "int64" - type: "integer" + format: "uuid" + type: "string" newConnBurst: description: "신규 연결 버스트 오버라이드" format: "int32" @@ -407,17 +409,17 @@ components: format: "int32" type: "integer" relayId: - format: "int64" - type: "integer" + format: "uuid" + type: "string" relayName: type: "string" status: $ref: "#/components/schemas/PortMappingStatus" suspendedBy: description: "정지한 관리자 id (자동 정지면 null)" - format: "int64" + format: "uuid" type: - - "integer" + - "string" - "null" suspendedReason: description: "정지 사유 (SUSPENDED일 때)" @@ -428,8 +430,8 @@ components: format: "int32" type: "integer" vmId: - format: "int64" - type: "integer" + format: "uuid" + type: "string" vmName: type: - "string" @@ -437,7 +439,6 @@ components: required: - "applyState" - "createdAt" - - "createdBy" - "id" - "proto" - "publicPort" @@ -475,8 +476,8 @@ components: enabled: type: "boolean" id: - format: "int64" - type: "integer" + format: "uuid" + type: "string" lastContactAt: format: "date-time" type: @@ -531,22 +532,22 @@ components: - "integer" - "null" domainId: - format: "int64" - type: "integer" + format: "uuid" + type: "string" domainKind: $ref: "#/components/schemas/DomainKind" fqdn: type: "string" id: - format: "int64" - type: "integer" + format: "uuid" + type: "string" lastError: type: - "string" - "null" orgId: - format: "int64" - type: "integer" + format: "uuid" + type: "string" orgName: type: "string" protocol: @@ -562,13 +563,13 @@ components: - "string" - "null" vmId: - format: "int64" - type: "integer" + format: "uuid" + type: "string" vmName: type: "string" workspaceId: - format: "int64" - type: "integer" + format: "uuid" + type: "string" workspaceName: type: "string" required: @@ -612,9 +613,9 @@ components: - "string" - "null" orgId: - format: "int64" + format: "uuid" type: - - "integer" + - "string" - "null" orgName: type: @@ -625,8 +626,8 @@ components: stepLabel: type: "string" taskId: - format: "int64" - type: "integer" + format: "uuid" + type: "string" totalSteps: format: "int32" type: "integer" @@ -634,8 +635,8 @@ components: format: "date-time" type: "string" vmId: - format: "int64" - type: "integer" + format: "uuid" + type: "string" vmName: type: - "string" @@ -666,8 +667,8 @@ components: - "string" - "null" id: - format: "int64" - type: "integer" + format: "uuid" + type: "string" kind: $ref: "#/components/schemas/WorkspaceKind" memberCount: @@ -701,8 +702,8 @@ components: name: type: "string" userId: - format: "int64" - type: "integer" + format: "uuid" + type: "string" userStatus: $ref: "#/components/schemas/UserStatus" workspaceRole: @@ -721,8 +722,8 @@ components: format: "date-time" type: "string" id: - format: "int64" - type: "integer" + format: "uuid" + type: "string" kind: $ref: "#/components/schemas/WorkspaceKind" memberCount: @@ -749,9 +750,9 @@ components: minLength: 0 type: "string" orgId: - format: "int64" + format: "uuid" type: - - "integer" + - "string" - "null" scope: $ref: "#/components/schemas/AnnouncementScope" @@ -760,9 +761,9 @@ components: minLength: 0 type: "string" workspaceId: - format: "int64" + format: "uuid" type: - - "integer" + - "string" - "null" required: - "body" @@ -781,12 +782,12 @@ components: format: "date-time" type: "string" id: - format: "int64" - type: "integer" + format: "uuid" + type: "string" orgId: - format: "int64" + format: "uuid" type: - - "integer" + - "string" - "null" recipientCount: format: "int32" @@ -796,9 +797,9 @@ components: title: type: "string" workspaceId: - format: "int64" + format: "uuid" type: - - "integer" + - "string" - "null" required: - "createdAt" @@ -815,8 +816,8 @@ components: email: type: "string" id: - format: "int64" - type: "integer" + format: "uuid" + type: "string" name: type: "string" rejectedCount: @@ -887,8 +888,8 @@ components: minimum: 1 type: "integer" grantedImageId: - format: "int64" - type: "integer" + format: "uuid" + type: "string" grantedMemoryMb: format: "int32" minimum: 256 @@ -903,9 +904,9 @@ components: minimum: 1 type: "integer" nodeId: - format: "int64" + format: "uuid" type: - - "integer" + - "string" - "null" required: - "grantedDiskGb" @@ -938,9 +939,9 @@ components: - "string" - "null" actorId: - format: "int64" + format: "uuid" type: - - "integer" + - "string" - "null" actorName: type: @@ -955,8 +956,8 @@ components: type: "string" detail: {} id: - format: "int64" - type: "integer" + description: "감사 로그 행 식별자. 목록 렌더링용 키이며 어떤 조회 파라미터도 아닙니다." + type: "string" ip: type: - "string" @@ -1020,8 +1021,8 @@ components: - "string" - "null" id: - format: "int64" - type: "integer" + format: "uuid" + type: "string" ports: description: "사용할 포트 번호 목록 (중복 제거·오름차순 정규화)" items: @@ -1037,15 +1038,15 @@ components: description: "신청 목적" type: "string" requestedBy: - format: "int64" - type: "integer" + format: "uuid" + type: "string" status: $ref: "#/components/schemas/CampusIpRequestStatus" description: "신청 상태 (REQUESTED = 신청, APPROVED = 관리자 승인, GRANTED = 교내 IP\ \ 연결 완료, REJECTED = 반려, REVOKED = 회수)" vmId: - format: "int64" - type: "integer" + format: "uuid" + type: "string" required: - "createdAt" - "id" @@ -1246,13 +1247,8 @@ components: maxLength: 100 minLength: 0 type: "string" - slug: - minLength: 1 - pattern: "^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$" - type: "string" required: - "name" - - "slug" type: "object" CreatePortForwardingRequest: properties: @@ -1290,8 +1286,8 @@ components: - "string" - "null" orgId: - format: "int64" - type: "integer" + format: "uuid" + type: "string" purpose: maxLength: 2000 minLength: 0 @@ -1313,8 +1309,8 @@ components: - $ref: "#/components/schemas/CreateVmRequestSpec" - type: "null" workspaceId: - format: "int64" - type: "integer" + format: "uuid" + type: "string" required: - "orgId" - "purpose" @@ -1382,11 +1378,11 @@ components: - "string" - "null" flavorId: - format: "int64" - type: "integer" + format: "uuid" + type: "string" imageId: - format: "int64" - type: "integer" + format: "uuid" + type: "string" reqDiskGb: format: "int32" minimum: 1 @@ -1464,8 +1460,8 @@ components: fqdn: type: "string" id: - format: "int64" - type: "integer" + format: "uuid" + type: "string" kind: $ref: "#/components/schemas/DomainKind" releasedAt: @@ -1494,8 +1490,8 @@ components: - "string" - "null" vmId: - format: "int64" - type: "integer" + format: "uuid" + type: "string" required: - "createdAt" - "fqdn" @@ -1526,8 +1522,8 @@ components: fqdn: type: "string" id: - format: "int64" - type: "integer" + format: "uuid" + type: "string" kind: $ref: "#/components/schemas/DomainKind" releasedAt: @@ -1552,8 +1548,8 @@ components: - "string" - "null" vmId: - format: "int64" - type: "integer" + format: "uuid" + type: "string" required: - "createdAt" - "fqdn" @@ -1602,8 +1598,8 @@ components: format: "date-time" type: "string" id: - format: "int64" - type: "integer" + format: "uuid" + type: "string" kind: $ref: "#/components/schemas/DriftFindingKind" lastSeenAt: @@ -1632,18 +1628,18 @@ components: - "string" - "null" resolvedById: - format: "int64" + format: "uuid" type: - - "integer" + - "string" - "null" status: $ref: "#/components/schemas/DriftFindingStatus" summary: type: "string" vmId: - format: "int64" + format: "uuid" type: - - "integer" + - "string" - "null" vmName: type: @@ -1693,8 +1689,8 @@ components: name: type: "string" userId: - format: "int64" - type: "integer" + format: "uuid" + type: "string" required: - "email" - "name" @@ -1711,8 +1707,8 @@ components: - $ref: "#/components/schemas/ReviewDecision" - type: "null" requestId: - format: "int64" - type: "integer" + format: "uuid" + type: "string" reviewerName: type: - "string" @@ -1737,13 +1733,13 @@ components: - "string" - "null" id: - format: "int64" - type: "integer" + format: "uuid" + type: "string" ip: type: "string" poolId: - format: "int64" - type: "integer" + format: "uuid" + type: "string" poolName: type: "string" releasedAt: @@ -1754,9 +1750,9 @@ components: status: $ref: "#/components/schemas/AllocationStatus" vmId: - format: "int64" + format: "uuid" type: - - "integer" + - "string" - "null" vmName: type: @@ -1781,8 +1777,8 @@ components: format: "int64" type: "integer" id: - format: "int64" - type: "integer" + format: "uuid" + type: "string" name: type: "string" required: @@ -1803,8 +1799,8 @@ components: format: "int64" type: "integer" id: - format: "int64" - type: "integer" + format: "uuid" + type: "string" name: type: "string" required: @@ -1852,8 +1848,8 @@ components: role: $ref: "#/components/schemas/WorkspaceMemberRole" userId: - format: "int64" - type: "integer" + format: "uuid" + type: "string" required: - "name" - "role" @@ -1864,8 +1860,8 @@ components: role: $ref: "#/components/schemas/WorkspaceMemberRole" workspaceId: - format: "int64" - type: "integer" + format: "uuid" + type: "string" workspaceKind: $ref: "#/components/schemas/WorkspaceKind" workspaceName: @@ -1950,8 +1946,8 @@ components: name: type: "string" nodeId: - format: "int64" - type: "integer" + format: "uuid" + type: "string" reachable: description: "true = 이 노드가 상태 조회에 응답함. false = 응답하지 않음이며 나머지 필드는 모두 null.\ \ true여도 스토리지 조회는 별도 권한이라 storage* 필드는 null일 수 있음" @@ -2058,8 +2054,8 @@ components: format: "double" type: "number" id: - format: "int64" - type: "integer" + format: "uuid" + type: "string" memoryAllocRatio: format: "double" type: "number" @@ -2107,8 +2103,8 @@ components: - "integer" - "null" id: - format: "int64" - type: "integer" + format: "uuid" + type: "string" ipPool: $ref: "#/components/schemas/IpPoolSummaryResponse" memoryAllocRatio: @@ -2174,8 +2170,8 @@ components: event: type: "string" id: - format: "int64" - type: "integer" + format: "uuid" + type: "string" importance: $ref: "#/components/schemas/NotificationImportance" linkPath: @@ -2245,11 +2241,9 @@ components: hidden: type: "boolean" id: - format: "int64" - type: "integer" - name: + format: "uuid" type: "string" - slug: + name: type: "string" status: $ref: "#/components/schemas/OrgStatus" @@ -2258,7 +2252,6 @@ components: - "hidden" - "id" - "name" - - "slug" - "status" type: "object" OrgHeadroom: @@ -2305,11 +2298,9 @@ components: hidden: type: "boolean" id: - format: "int64" - type: "integer" - name: + format: "uuid" type: "string" - slug: + name: type: "string" status: $ref: "#/components/schemas/OrgStatus" @@ -2317,7 +2308,6 @@ components: - "hidden" - "id" - "name" - - "slug" - "status" type: "object" OsImageResponse: @@ -2325,8 +2315,8 @@ components: displayName: type: "string" id: - format: "int64" - type: "integer" + format: "uuid" + type: "string" minDiskGb: format: "int32" type: "integer" @@ -2870,8 +2860,8 @@ components: format: "date-time" type: "string" id: - format: "int64" - type: "integer" + format: "uuid" + type: "string" proto: $ref: "#/components/schemas/PortMappingProto" description: "프로토콜 (tcp | udp)" @@ -3054,8 +3044,8 @@ components: RelayTokenResponse: properties: relayId: - format: "int64" - type: "integer" + format: "uuid" + type: "string" token: description: "새 동기화 토큰(64자 hex). 이 응답에서만 확인 가능하며 저장되지 않습니다" type: "string" @@ -3081,11 +3071,14 @@ components: - "string" - "null" id: - format: "int64" - type: "integer" + format: "uuid" + type: "string" orgId: - format: "int64" - type: "integer" + description: "신청 대상 기관. 행이 사라진 경우에만 null입니다." + format: "uuid" + type: + - "string" + - "null" orgName: type: "string" purpose: @@ -3101,8 +3094,11 @@ components: - "string" - "null" requesterId: - format: "int64" - type: "integer" + description: "신청자. 행이 사라진 경우에만 null입니다." + format: "uuid" + type: + - "string" + - "null" requesterName: type: "string" review: @@ -3121,22 +3117,22 @@ components: - $ref: "#/components/schemas/VmRequestSpecResponse" - type: "null" workspaceId: - format: "int64" - type: "integer" + description: "신청 대상 워크스페이스. 행이 사라진 경우에만 null입니다." + format: "uuid" + type: + - "string" + - "null" workspaceName: type: "string" required: - "createdAt" - "id" - - "orgId" - "orgName" - "purpose" - - "requesterId" - "requesterName" - "status" - "type" - "updatedAt" - - "workspaceId" - "workspaceName" type: "object" RequestOptionsResponse: @@ -3178,14 +3174,16 @@ components: - "string" - "null" reviewerId: - format: "int64" - type: "integer" + description: "결재자. 계정 행이 사라진 경우에만 null입니다." + format: "uuid" + type: + - "string" + - "null" reviewerName: type: "string" required: - "decidedAt" - "decision" - - "reviewerId" - "reviewerName" type: "object" RequestStatus: @@ -3269,8 +3267,8 @@ components: $ref: "#/components/schemas/AccessGranteeType" description: "대상 종류 — USER는 지정된 사용자, WORKSPACE은 소유 워크스페이스 전체" id: - format: "int64" - type: "integer" + format: "uuid" + type: "string" role: $ref: "#/components/schemas/ResourceRole" description: "이 대상이 이 VM에서 갖는 등급" @@ -3309,8 +3307,8 @@ components: - "string" - "null" id: - format: "int64" - type: "integer" + format: "uuid" + type: "string" name: type: "string" status: @@ -3319,8 +3317,8 @@ components: type: $ref: "#/components/schemas/ResourceType" workspaceId: - format: "int64" - type: "integer" + format: "uuid" + type: "string" workspaceName: type: "string" required: @@ -3352,8 +3350,8 @@ components: - "string" - "null" id: - format: "int64" - type: "integer" + format: "uuid" + type: "string" name: type: "string" ownerNames: @@ -3365,8 +3363,8 @@ components: type: $ref: "#/components/schemas/ResourceType" workspaceId: - format: "int64" - type: "integer" + format: "uuid" + type: "string" workspaceName: type: "string" required: @@ -3597,8 +3595,8 @@ components: fingerprint: type: "string" id: - format: "int64" - type: "integer" + format: "uuid" + type: "string" lastUsedAt: format: "date-time" type: @@ -3720,8 +3718,8 @@ components: clientIp: type: "string" orgId: - format: "int64" - type: "integer" + format: "uuid" + type: "string" orgName: type: "string" sessionId: @@ -3732,13 +3730,13 @@ components: userEmail: type: "string" userId: - format: "int64" - type: "integer" + format: "uuid" + type: "string" userName: type: "string" vmId: - format: "int64" - type: "integer" + format: "uuid" + type: "string" vmName: type: "string" workspaceName: @@ -3828,8 +3826,8 @@ components: format: "int64" type: "integer" workspaceId: - format: "int64" - type: "integer" + format: "uuid" + type: "string" required: - "name" - "vmCount" @@ -3950,9 +3948,9 @@ components: UpdateUserAdminRequest: properties: orgId: - format: "int64" + format: "uuid" type: - - "integer" + - "string" - "null" role: $ref: "#/components/schemas/UserRole" @@ -4035,8 +4033,8 @@ components: email: type: "string" id: - format: "int64" - type: "integer" + format: "uuid" + type: "string" memberships: items: $ref: "#/components/schemas/Membership" @@ -4046,9 +4044,9 @@ components: name: type: "string" orgId: - format: "int64" + format: "uuid" type: - - "integer" + - "string" - "null" role: $ref: "#/components/schemas/UserRole" @@ -4083,16 +4081,16 @@ components: email: type: "string" id: - format: "int64" - type: "integer" + format: "uuid" + type: "string" mfaEnabled: type: "boolean" name: type: "string" orgId: - format: "int64" + format: "uuid" type: - - "integer" + - "string" - "null" role: $ref: "#/components/schemas/UserRole" @@ -4112,8 +4110,8 @@ components: email: type: "string" id: - format: "int64" - type: "integer" + format: "uuid" + type: "string" memberships: items: $ref: "#/components/schemas/Membership" @@ -4123,9 +4121,9 @@ components: name: type: "string" orgId: - format: "int64" + format: "uuid" type: - - "integer" + - "string" - "null" pendingConsents: items: @@ -4167,9 +4165,9 @@ components: - "string" - "null" actorId: - format: "int64" + format: "uuid" type: - - "integer" + - "string" - "null" changedAt: format: "date-time" @@ -4192,8 +4190,8 @@ components: email: type: "string" id: - format: "int64" - type: "integer" + format: "uuid" + type: "string" name: type: "string" role: @@ -4223,8 +4221,8 @@ components: - "string" - "null" id: - format: "int64" - type: "integer" + format: "uuid" + type: "string" memoryMb: format: "int32" type: "integer" @@ -4263,8 +4261,10 @@ components: format: "date-time" type: "string" requestedById: - format: "int64" - type: "integer" + format: "uuid" + type: + - "string" + - "null" scheduledFor: format: "date-time" type: "string" @@ -4272,7 +4272,6 @@ components: - "cancelable" - "kind" - "requestedAt" - - "requestedById" - "scheduledFor" type: "object" VmDetailResponse: @@ -4313,11 +4312,14 @@ components: hostname: type: "string" id: - format: "int64" - type: "integer" + format: "uuid" + type: "string" imageId: - format: "int64" - type: "integer" + description: "생성에 쓰인 OS 이미지. 행이 사라진 경우에만 null입니다." + format: "uuid" + type: + - "string" + - "null" ipAddress: type: - "string" @@ -4332,8 +4334,11 @@ components: name: type: "string" orgId: - format: "int64" - type: "integer" + description: "소속 기관. 행이 사라진 경우에만 null입니다." + format: "uuid" + type: + - "string" + - "null" orgName: type: - "string" @@ -4354,8 +4359,11 @@ components: $ref: "#/components/schemas/PublicationView" type: "array" requestId: - format: "int64" - type: "integer" + description: "이 VM을 만든 신청. 행이 사라진 경우에만 null입니다." + format: "uuid" + type: + - "string" + - "null" settingsEditAllowed: description: "VM 설정을 볼·바꿀 수 있는지" type: "boolean" @@ -4383,8 +4391,11 @@ components: format: "int32" type: "integer" workspaceId: - format: "int64" - type: "integer" + description: "소유 워크스페이스. 행이 사라진 경우에만 null입니다." + format: "uuid" + type: + - "string" + - "null" workspaceName: type: "string" required: @@ -4395,15 +4406,12 @@ components: - "diskGb" - "hostname" - "id" - - "imageId" - "memoryMb" - "name" - - "orgId" - "passwordAvailable" - "passwordRevealAllowed" - "powerControlAllowed" - "publications" - - "requestId" - "settingsEditAllowed" - "sshGatewayBlocked" - "sshHost" @@ -4411,15 +4419,14 @@ components: - "status" - "updatedAt" - "vcpu" - - "workspaceId" - "workspaceName" type: "object" VmEventResponse: properties: actorId: - format: "int64" + format: "uuid" type: - - "integer" + - "string" - "null" createdAt: format: "date-time" @@ -4429,8 +4436,8 @@ components: - "string" - "null" id: - format: "int64" - type: "integer" + format: "uuid" + type: "string" type: $ref: "#/components/schemas/VmEventType" required: @@ -4468,8 +4475,8 @@ components: displayName: type: "string" id: - format: "int64" - type: "integer" + format: "uuid" + type: "string" memoryMb: format: "int32" type: "integer" @@ -4514,8 +4521,8 @@ components: format: "int32" type: "integer" grantedImageId: - format: "int64" - type: "integer" + format: "uuid" + type: "string" grantedMemoryMb: format: "int32" type: "integer" @@ -4523,9 +4530,9 @@ components: format: "int32" type: "integer" nodeId: - format: "int64" + format: "uuid" type: - - "integer" + - "string" - "null" required: - "grantedDiskGb" @@ -4648,17 +4655,17 @@ components: - "string" - "null" flavorId: - format: "int64" + format: "uuid" type: - - "integer" + - "string" - "null" granted: anyOf: - $ref: "#/components/schemas/VmGrantedSpecResponse" - type: "null" imageId: - format: "int64" - type: "integer" + format: "uuid" + type: "string" reqDiskGb: format: "int32" type: "integer" @@ -4786,8 +4793,8 @@ components: - "string" - "null" id: - format: "int64" - type: "integer" + format: "uuid" + type: "string" memoryMb: description: "메모리(MiB). 접근 권한이 없으면 생략됩니다." format: "int32" @@ -4807,9 +4814,9 @@ components: type: "string" type: "array" requestId: - format: "int64" + format: "uuid" type: - - "integer" + - "string" - "null" sshGatewayBlocked: type: @@ -4828,8 +4835,11 @@ components: - "integer" - "null" workspaceId: - format: "int64" - type: "integer" + description: "소유 워크스페이스. 행이 사라진 경우에만 null입니다." + format: "uuid" + type: + - "string" + - "null" workspaceName: type: "string" required: @@ -4840,7 +4850,6 @@ components: - "name" - "ownerNames" - "status" - - "workspaceId" - "workspaceName" type: "object" WithdrawRequest: @@ -4866,8 +4875,8 @@ components: - "string" - "null" id: - format: "int64" - type: "integer" + format: "uuid" + type: "string" kind: $ref: "#/components/schemas/WorkspaceKind" members: @@ -4901,8 +4910,8 @@ components: role: $ref: "#/components/schemas/WorkspaceMemberRole" userId: - format: "int64" - type: "integer" + format: "uuid" + type: "string" required: - "email" - "name" @@ -4921,8 +4930,8 @@ components: $ref: "#/components/schemas/VmBriefResponse" type: "array" id: - format: "int64" - type: "integer" + format: "uuid" + type: "string" kind: $ref: "#/components/schemas/WorkspaceKind" members: @@ -4948,8 +4957,8 @@ components: - "string" - "null" id: - format: "int64" - type: "integer" + format: "uuid" + type: "string" kind: $ref: "#/components/schemas/WorkspaceKind" memberCount: @@ -4969,7 +4978,7 @@ components: securitySchemes: bearerAuth: bearerFormat: "JWT" - description: "액세스 토큰 (JWT HS256, 15분 만료). 클레임: sub, role, org_id, token_version.\ + description: "액세스 토큰 (JWT HS256, 15분 만료). 클레임: sub(계정 공개 식별자 UUID), role, token_version.\ \ 비밀번호 변경·계정 비활성화 시 token_version이 올라가 기존 토큰이 즉시 무효화됩니다. 리프레시 토큰은 보안 스킴이 아\ 니라 __Host-pickle_refresh httpOnly 쿠키로만 오갑니다." scheme: "bearer" @@ -4978,7 +4987,7 @@ info: description: "부산대학교 클라우드 플랫폼 Pickle의 REST API. 인증은 JWT Bearer, 오류 응답은 RFC 9457 problem+json(Problem\ \ 스키마)을 따릅니다." title: "Pickle API" - version: "0.37.0" + version: "0.38.0" openapi: "3.1.0" paths: /admin/announcements: @@ -5080,8 +5089,8 @@ paths: name: "orgId" required: false schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" - in: "query" name: "page" required: false @@ -5127,8 +5136,8 @@ paths: name: "vmId" required: false schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" - in: "query" name: "page" required: false @@ -5169,8 +5178,8 @@ paths: name: "requestId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" requestBody: content: application/json: @@ -5209,8 +5218,8 @@ paths: name: "orgId" required: false schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" responses: "200": content: @@ -5234,8 +5243,8 @@ paths: name: "orgId" required: false schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" - in: "query" name: "status" required: false @@ -5288,8 +5297,8 @@ paths: name: "orgId" required: false schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" - in: "query" name: "kind" required: false @@ -5340,8 +5349,8 @@ paths: name: "domainId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" responses: "200": content: @@ -5365,8 +5374,8 @@ paths: name: "domainId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" responses: "202": content: @@ -5436,8 +5445,8 @@ paths: name: "findingId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" requestBody: content: application/json: @@ -5466,8 +5475,8 @@ paths: name: "poolId" required: false schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" - in: "query" name: "status" required: false @@ -5533,8 +5542,8 @@ paths: name: "nodeId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" requestBody: content: application/json: @@ -5564,8 +5573,8 @@ paths: name: "nodeId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" - description: "조회 구간 — HOUR/DAY/WEEK/MONTH/YEAR (해상도는 구간에 따라 거칠어짐)" in: "query" name: "timeframe" @@ -5647,8 +5656,8 @@ paths: name: "notificationId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" responses: "202": content: @@ -5715,8 +5724,8 @@ paths: name: "orgId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" requestBody: content: application/json: @@ -5766,8 +5775,8 @@ paths: name: "imageId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" requestBody: content: application/json: @@ -5797,14 +5806,14 @@ paths: name: "relayId" required: false schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" - in: "query" name: "vmId" required: false schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" - in: "query" name: "status" required: false @@ -5850,8 +5859,8 @@ paths: name: "mappingId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" responses: "202": content: @@ -5875,8 +5884,8 @@ paths: name: "mappingId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" requestBody: content: application/json: @@ -5906,8 +5915,8 @@ paths: name: "mappingId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" requestBody: content: application/json: @@ -5937,8 +5946,8 @@ paths: name: "mappingId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" responses: "200": content: @@ -5982,8 +5991,8 @@ paths: name: "relayId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" - description: "재인증(sudo-mode) 토큰 — POST /auth/reverify가 발급 (10분 유효, 다회용). 없\ 거나 만료·무효면 403 REAUTH_REQUIRED." in: "header" @@ -6030,8 +6039,8 @@ paths: name: "orgId" required: false schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" - in: "query" name: "page" required: false @@ -6072,8 +6081,8 @@ paths: name: "requestId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" responses: "200": content: @@ -6097,8 +6106,8 @@ paths: name: "requestId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" requestBody: content: application/json: @@ -6128,8 +6137,8 @@ paths: name: "requestId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" responses: "200": content: @@ -6153,8 +6162,8 @@ paths: name: "requestId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" requestBody: content: application/json: @@ -6184,8 +6193,8 @@ paths: name: "orgId" required: false schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" - in: "query" name: "status" required: false @@ -6249,8 +6258,8 @@ paths: name: "routeId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" responses: "202": content: @@ -6324,8 +6333,8 @@ paths: name: "orgId" required: false schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" responses: "200": content: @@ -6379,8 +6388,8 @@ paths: name: "vmId" required: false schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" - in: "query" name: "page" required: false @@ -6421,8 +6430,8 @@ paths: name: "taskId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" responses: "202": content: @@ -6503,8 +6512,8 @@ paths: name: "orgId" required: false schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" - in: "query" name: "sort" required: false @@ -6557,8 +6566,8 @@ paths: name: "userId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" responses: "200": content: @@ -6581,8 +6590,8 @@ paths: name: "userId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" requestBody: content: application/json: @@ -6612,8 +6621,8 @@ paths: name: "userId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" requestBody: content: application/json: @@ -6643,8 +6652,8 @@ paths: name: "userId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" responses: "200": content: @@ -6668,8 +6677,8 @@ paths: name: "userId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" responses: "200": content: @@ -6736,8 +6745,8 @@ paths: name: "flavorId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" requestBody: content: application/json: @@ -6767,14 +6776,14 @@ paths: name: "orgId" required: false schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" - in: "query" name: "workspaceId" required: false schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" - in: "query" name: "status" required: false @@ -6851,8 +6860,8 @@ paths: name: "vmId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" responses: "200": content: @@ -6876,8 +6885,8 @@ paths: name: "vmId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" responses: "200": content: @@ -6901,8 +6910,8 @@ paths: name: "vmId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" - in: "query" name: "page" required: false @@ -6943,8 +6952,8 @@ paths: name: "vmId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" requestBody: content: application/json: @@ -6974,8 +6983,8 @@ paths: name: "vmId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" responses: "202": content: @@ -6999,8 +7008,8 @@ paths: name: "vmId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" requestBody: content: application/json: @@ -7030,8 +7039,8 @@ paths: name: "vmId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" requestBody: content: application/json: @@ -7061,8 +7070,8 @@ paths: name: "vmId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" responses: "202": content: @@ -7086,8 +7095,8 @@ paths: name: "vmId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" requestBody: content: application/json: @@ -7117,8 +7126,8 @@ paths: name: "vmId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" responses: "202": content: @@ -7142,8 +7151,8 @@ paths: name: "vmId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" responses: "202": content: @@ -7167,8 +7176,8 @@ paths: name: "orgId" required: false schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" responses: "200": content: @@ -7194,8 +7203,8 @@ paths: name: "workspaceId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" responses: "200": content: @@ -7478,8 +7487,8 @@ paths: name: "vmId" required: false schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" - in: "query" name: "status" required: false @@ -7525,8 +7534,8 @@ paths: name: "domainId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" responses: "202": content: @@ -7549,8 +7558,8 @@ paths: name: "domainId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" responses: "200": content: @@ -7573,8 +7582,8 @@ paths: name: "domainId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" requestBody: content: application/json: @@ -7604,8 +7613,8 @@ paths: name: "domainId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" responses: "202": content: @@ -7960,8 +7969,8 @@ paths: name: "keyId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" - description: "재인증(sudo-mode) 토큰 — POST /auth/reverify가 발급 (10분 유효, 다회용). 없\ 거나 만료·무효면 403 REAUTH_REQUIRED." in: "header" @@ -7994,8 +8003,8 @@ paths: name: "keyId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" - description: "재인증(sudo-mode) 토큰 — POST /auth/reverify가 발급 (10분 유효, 다회용). 없\ 거나 만료·무효면 403 REAUTH_REQUIRED." in: "header" @@ -8217,8 +8226,8 @@ paths: name: "notificationId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" responses: "200": content: @@ -8292,8 +8301,8 @@ paths: name: "workspaceId" required: false schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" - in: "query" name: "page" required: false @@ -8357,8 +8366,8 @@ paths: name: "requestId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" responses: "200": content: @@ -8382,8 +8391,8 @@ paths: name: "requestId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" responses: "200": content: @@ -8412,8 +8421,8 @@ paths: name: "workspaceId" required: false schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" - in: "query" name: "page" required: false @@ -8474,8 +8483,8 @@ paths: name: "workspaceId" required: false schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" - in: "query" name: "page" required: false @@ -8516,8 +8525,8 @@ paths: name: "vmId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" - description: "재인증(sudo-mode) 토큰 — POST /auth/reverify가 발급 (10분 유효, 다회용). 없\ 거나 만료·무효면 403 REAUTH_REQUIRED." in: "header" @@ -8553,8 +8562,8 @@ paths: name: "vmId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" responses: "200": content: @@ -8580,8 +8589,8 @@ paths: name: "vmId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" responses: "200": content: @@ -8607,8 +8616,8 @@ paths: name: "vmId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" - description: "재인증(sudo-mode) 토큰 — POST /auth/reverify가 발급 (10분 유효, 다회용). 없\ 거나 만료·무효면 403 REAUTH_REQUIRED." in: "header" @@ -8654,14 +8663,14 @@ paths: name: "vmId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" - in: "path" name: "grantId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" - description: "재인증(sudo-mode) 토큰 — POST /auth/reverify가 발급 (10분 유효, 다회용). 없\ 거나 만료·무효면 403 REAUTH_REQUIRED." in: "header" @@ -8694,14 +8703,14 @@ paths: name: "vmId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" - in: "path" name: "grantId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" - description: "재인증(sudo-mode) 토큰 — POST /auth/reverify가 발급 (10분 유효, 다회용). 없\ 거나 만료·무효면 403 REAUTH_REQUIRED." in: "header" @@ -8745,8 +8754,8 @@ paths: name: "vmId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" responses: "200": content: @@ -8771,8 +8780,8 @@ paths: name: "vmId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" requestBody: content: application/json: @@ -8802,14 +8811,14 @@ paths: name: "vmId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" - in: "path" name: "requestId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" responses: "204": description: "No Content" @@ -8829,8 +8838,8 @@ paths: name: "vmId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" requestBody: content: application/json: @@ -8859,8 +8868,8 @@ paths: name: "vmId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" - in: "query" name: "page" required: false @@ -8901,8 +8910,8 @@ paths: name: "vmId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" responses: "202": content: @@ -8926,8 +8935,8 @@ paths: name: "vmId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" - description: "조회 구간 — HOUR/DAY/WEEK/MONTH/YEAR (해상도는 구간에 따라 거칠어짐)" in: "query" name: "timeframe" @@ -8958,8 +8967,8 @@ paths: name: "vmId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" - description: "재인증(sudo-mode) 토큰 — POST /auth/reverify가 발급 (10분 유효, 다회용). 없\ 거나 만료·무효면 403 REAUTH_REQUIRED." in: "header" @@ -8996,8 +9005,8 @@ paths: name: "vmId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" - description: "재인증(sudo-mode) 토큰 — POST /auth/reverify가 발급 (10분 유효, 다회용). 없\ 거나 만료·무효면 403 REAUTH_REQUIRED." in: "header" @@ -9034,8 +9043,8 @@ paths: name: "vmId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" responses: "200": content: @@ -9060,8 +9069,8 @@ paths: name: "vmId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" requestBody: content: application/json: @@ -9091,14 +9100,14 @@ paths: name: "vmId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" - in: "path" name: "portForwardingId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" responses: "202": content: @@ -9122,8 +9131,8 @@ paths: name: "vmId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" responses: "202": content: @@ -9147,8 +9156,8 @@ paths: name: "vmId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" responses: "200": content: @@ -9173,8 +9182,8 @@ paths: name: "vmId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" - description: "재인증(sudo-mode) 토큰 — POST /auth/reverify가 발급 (10분 유효, 다회용). 없\ 거나 만료·무효면 403 REAUTH_REQUIRED." in: "header" @@ -9219,8 +9228,8 @@ paths: name: "vmId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" responses: "202": content: @@ -9244,8 +9253,8 @@ paths: name: "vmId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" responses: "202": content: @@ -9269,8 +9278,8 @@ paths: name: "vmId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" responses: "201": content: @@ -9337,8 +9346,8 @@ paths: name: "workspaceId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" responses: "204": description: "No Content" @@ -9357,8 +9366,8 @@ paths: name: "workspaceId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" responses: "200": content: @@ -9381,8 +9390,8 @@ paths: name: "workspaceId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" requestBody: content: application/json: @@ -9412,8 +9421,8 @@ paths: name: "workspaceId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" - description: "재인증(sudo-mode) 토큰 — POST /auth/reverify가 발급 (10분 유효, 다회용). 없\ 거나 만료·무효면 403 REAUTH_REQUIRED." in: "header" @@ -9456,14 +9465,14 @@ paths: name: "workspaceId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" - in: "path" name: "userId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" - description: "재인증(sudo-mode) 토큰 — POST /auth/reverify가 발급 (10분 유효, 다회용). 없\ 거나 만료·무효면 403 REAUTH_REQUIRED." in: "header" @@ -9495,14 +9504,14 @@ paths: name: "workspaceId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" - in: "path" name: "userId" required: true schema: - format: "int64" - type: "integer" + format: "uuid" + type: "string" - description: "재인증(sudo-mode) 토큰 — POST /auth/reverify가 발급 (10분 유효, 다회용). 없\ 거나 만료·무효면 403 REAUTH_REQUIRED." in: "header" diff --git a/src/main/java/kr/ac/pusan/pickle/access/ResourceAccessGrant.java b/src/main/java/kr/ac/pusan/pickle/access/ResourceAccessGrant.java index cd3b3f4d..ddfb1267 100644 --- a/src/main/java/kr/ac/pusan/pickle/access/ResourceAccessGrant.java +++ b/src/main/java/kr/ac/pusan/pickle/access/ResourceAccessGrant.java @@ -9,6 +9,7 @@ import jakarta.persistence.Id; import jakarta.persistence.Table; import java.time.Instant; +import java.util.UUID; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.annotations.UpdateTimestamp; @@ -33,6 +34,14 @@ public class ResourceAccessGrant { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + /** + * The identifier this row wears outside the API boundary. Internal joins, + * sorts and foreign keys keep using {@link #id}. + */ + @JdbcTypeCode(SqlTypes.UUID) + @Column(name = "public_id", nullable = false, updatable = false, unique = true) + private UUID publicId = UUID.randomUUID(); + @Enumerated(EnumType.STRING) @JdbcTypeCode(SqlTypes.NAMED_ENUM) @Column(name = "resource_type", nullable = false, columnDefinition = "resource_type") @@ -90,6 +99,10 @@ public Long getId() { return id; } + public UUID getPublicId() { + return publicId; + } + public ResourceType getResourceType() { return resourceType; } diff --git a/src/main/java/kr/ac/pusan/pickle/access/ResourceAccessGrantRepository.java b/src/main/java/kr/ac/pusan/pickle/access/ResourceAccessGrantRepository.java index 3a1cf1dd..4b7c2f64 100644 --- a/src/main/java/kr/ac/pusan/pickle/access/ResourceAccessGrantRepository.java +++ b/src/main/java/kr/ac/pusan/pickle/access/ResourceAccessGrantRepository.java @@ -3,6 +3,7 @@ import java.util.Collection; import java.util.List; import java.util.Optional; +import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -10,6 +11,9 @@ public interface ResourceAccessGrantRepository extends JpaRepository { + /** Resolution of the identifier this row wears outside the API boundary. */ + Optional findByPublicId(UUID publicId); + /** * Every grant on one resource — the two rows the judgment needs (the * requester's own and the workspace-wide one) plus the list surfaces' contents. diff --git a/src/main/java/kr/ac/pusan/pickle/access/ResourceAccessGrantService.java b/src/main/java/kr/ac/pusan/pickle/access/ResourceAccessGrantService.java index 7724a200..498c3894 100644 --- a/src/main/java/kr/ac/pusan/pickle/access/ResourceAccessGrantService.java +++ b/src/main/java/kr/ac/pusan/pickle/access/ResourceAccessGrantService.java @@ -4,6 +4,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.UUID; import java.util.function.Function; import java.util.stream.Collectors; import kr.ac.pusan.pickle.access.dto.AddResourceAccessGrantRequest; @@ -77,22 +78,25 @@ public ResourceAccessGrantService(List adapters, @Transactional(readOnly = true) public ResourceAccessListResponse list(AuthenticatedUser actor, ResourceType type, - long resourceId) { + UUID resourceId) { Managed managed = requireManager(actor, type, resourceId); - String workspaceName = workspaceRepository.findById(managed.resource().workspaceId()) - .map(Workspace::getName).orElse(""); - return ResourceAccessListResponse.of(type, managed.resource(), workspaceName, - views(type, resourceId)); + Workspace workspace = workspaceRepository.findById(managed.resource().workspaceId()) + .orElse(null); + return ResourceAccessListResponse.of(type, managed.resource(), + workspace == null ? null : workspace.getPublicId(), + workspace == null ? "" : workspace.getName(), + views(type, managed.resource().id())); } @Transactional - public ResourceAccessGrantView add(AuthenticatedUser actor, ResourceType type, long resourceId, + public ResourceAccessGrantView add(AuthenticatedUser actor, ResourceType type, UUID resourceId, AddResourceAccessGrantRequest request, String ip) { Managed managed = requireManager(actor, type, resourceId); + long id = managed.resource().id(); ResourceAccessGrant grant = request.granteeType() == AccessGranteeType.WORKSPACE - ? ResourceAccessGrant.forOwningWorkspace(type, resourceId, + ? ResourceAccessGrant.forOwningWorkspace(type, id, requireWorkspaceWideRole(request.role())) - : ResourceAccessGrant.forUser(type, resourceId, + : ResourceAccessGrant.forUser(type, id, requireEligibleMember(managed, request.userId()), request.role()); ResourceAccessGrant saved; try { @@ -106,7 +110,7 @@ public ResourceAccessGrantView add(AuthenticatedUser actor, ResourceType type, l @Transactional public ResourceAccessGrantView update(AuthenticatedUser actor, ResourceType type, - long resourceId, long grantId, UpdateResourceAccessGrantRequest request, String ip) { + UUID resourceId, UUID grantId, UpdateResourceAccessGrantRequest request, String ip) { Managed managed = requireManager(actor, type, resourceId); ResourceAccessGrant grant = requireGrant(managed, grantId); ResourceRole previous = grant.getRole(); @@ -118,7 +122,7 @@ public ResourceAccessGrantView update(AuthenticatedUser actor, ResourceType type } @Transactional - public void remove(AuthenticatedUser actor, ResourceType type, long resourceId, long grantId, + public void remove(AuthenticatedUser actor, ResourceType type, UUID resourceId, UUID grantId, String ip) { Managed managed = requireManager(actor, type, resourceId); ResourceAccessGrant grant = requireGrant(managed, grantId); @@ -127,11 +131,11 @@ public void remove(AuthenticatedUser actor, ResourceType type, long resourceId, } /** Managing the list is a resource owner's right, or a workspace owner's standing one. */ - private Managed requireManager(AuthenticatedUser actor, ResourceType type, long resourceId) { + private Managed requireManager(AuthenticatedUser actor, ResourceType type, UUID resourceId) { ResourceTypeAdapter adapter = adapterFor(type); - ResourceIdentity resource = adapter.identify(resourceId) + ResourceIdentity resource = adapter.identifyByPublicId(resourceId) .orElseThrow(() -> adapter.accessMessages().notFound()); - ResourceStanding standing = resolver.standing(type, resourceId, resource.workspaceId(), + ResourceStanding standing = resolver.standing(type, resource.id(), resource.workspaceId(), actor.id()); if (!standing.manages()) { // An outsider is not told the resource exists and a member without @@ -156,16 +160,20 @@ private ResourceTypeAdapter adapterFor(ResourceType type) { * rule that keeps the list from disagreeing with the 404 that hides the * resource from everyone else. */ - private long requireEligibleMember(Managed managed, Long userId) { - if (userId == null) { + private long requireEligibleMember(Managed managed, UUID publicUserId) { + if (publicUserId == null) { throw ApiException.validationFailed(List.of(new FieldValidationError("userId", "대상 사용자를 지정해 주세요."))); } + // An id no account has is refused as ineligible, not as missing: the + // grantee's existence is not this endpoint's to disclose. + Long userId = userRepository.findByPublicId(publicUserId) + .filter(user -> user.getStatus() == UserStatus.ACTIVE) + .map(kr.ac.pusan.pickle.user.User::getId) + .orElseThrow(() -> managed.messages().granteeIneligible()); boolean member = workspaceMemberRepository .findByWorkspaceIdAndUserId(managed.resource().workspaceId(), userId).isPresent(); - boolean active = userRepository.findById(userId) - .filter(user -> user.getStatus() == UserStatus.ACTIVE).isPresent(); - if (!member || !active) { + if (!member) { throw managed.messages().granteeIneligible(); } return userId; @@ -182,8 +190,8 @@ private ResourceRole requireWorkspaceWideRole(ResourceRole role) { } /** A grant id means nothing outside the resource it was issued under. */ - private ResourceAccessGrant requireGrant(Managed managed, long grantId) { - return grantRepository.findById(grantId) + private ResourceAccessGrant requireGrant(Managed managed, UUID grantId) { + return grantRepository.findByPublicId(grantId) .filter(grant -> grant.getResourceType() == managed.adapter().type() && grant.getResourceId() == managed.resource().id()) .orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, @@ -204,12 +212,11 @@ private void audit(AuthenticatedUser actor, Managed managed, GrantChange change, if (previousRole != null) { detail.put("previousRole", previousRole.name()); } - long resourceId = managed.resource().id(); auditService.recordAfterCommit(actor.id(), actor.role().name(), names.actionOf(change), - names.targetType(), resourceId, Map.copyOf(detail), ip); + names.targetType(), managed.resource().publicId(), Map.copyOf(detail), ip); if (opensTheDoorForActor(actor, managed, change, grant)) { auditService.recordAfterCommit(actor.id(), actor.role().name(), names.breakGlass(), - names.targetType(), resourceId, Map.copyOf(detail), ip); + names.targetType(), managed.resource().publicId(), Map.copyOf(detail), ip); } } diff --git a/src/main/java/kr/ac/pusan/pickle/access/VmAccessGrantController.java b/src/main/java/kr/ac/pusan/pickle/access/VmAccessGrantController.java index 46e541a1..684d343a 100644 --- a/src/main/java/kr/ac/pusan/pickle/access/VmAccessGrantController.java +++ b/src/main/java/kr/ac/pusan/pickle/access/VmAccessGrantController.java @@ -6,6 +6,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; +import java.util.UUID; import kr.ac.pusan.pickle.access.dto.AddResourceAccessGrantRequest; import kr.ac.pusan.pickle.access.dto.UpdateResourceAccessGrantRequest; import kr.ac.pusan.pickle.access.dto.ResourceAccessGrantView; @@ -47,7 +48,7 @@ public VmAccessGrantController(ResourceAccessGrantService service) { description = "이 VM의 접근 권한 전체와, 그 목록이 어느 VM의 것인지 알려 주는 최소 정보입니다. VM 소유자와 워크스페이스 소유자만 볼 수 있습니다.") public ResourceAccessListResponse listVmAccessGrants( @AuthenticationPrincipal AuthenticatedUser principal, - @PathVariable long vmId) { + @PathVariable UUID vmId) { return service.list(principal, ResourceType.VM, vmId); } @@ -60,7 +61,7 @@ public ResourceAccessListResponse listVmAccessGrants( + "참여자·열람자까지만 부여할 수 있습니다.") public ResourceAccessGrantView addVmAccessGrant( @AuthenticationPrincipal AuthenticatedUser principal, - @PathVariable long vmId, + @PathVariable UUID vmId, @Valid @RequestBody AddResourceAccessGrantRequest request, HttpServletRequest httpRequest) { return service.add(principal, ResourceType.VM, vmId, request, clientIp(httpRequest)); @@ -71,8 +72,8 @@ public ResourceAccessGrantView addVmAccessGrant( @Operation(summary = "접근 권한 등급 변경") public ResourceAccessGrantView updateVmAccessGrant( @AuthenticationPrincipal AuthenticatedUser principal, - @PathVariable long vmId, - @PathVariable long grantId, + @PathVariable UUID vmId, + @PathVariable UUID grantId, @Valid @RequestBody UpdateResourceAccessGrantRequest request, HttpServletRequest httpRequest) { return service.update(principal, ResourceType.VM, vmId, grantId, request, @@ -87,8 +88,8 @@ public ResourceAccessGrantView updateVmAccessGrant( + "않습니다. 필요하면 비밀번호를 재생성해 주세요.") public void removeVmAccessGrant( @AuthenticationPrincipal AuthenticatedUser principal, - @PathVariable long vmId, - @PathVariable long grantId, + @PathVariable UUID vmId, + @PathVariable UUID grantId, HttpServletRequest httpRequest) { service.remove(principal, ResourceType.VM, vmId, grantId, clientIp(httpRequest)); } diff --git a/src/main/java/kr/ac/pusan/pickle/access/VmAccessService.java b/src/main/java/kr/ac/pusan/pickle/access/VmAccessService.java index 732996fd..580471d4 100644 --- a/src/main/java/kr/ac/pusan/pickle/access/VmAccessService.java +++ b/src/main/java/kr/ac/pusan/pickle/access/VmAccessService.java @@ -1,5 +1,6 @@ package kr.ac.pusan.pickle.access; +import java.util.UUID; import kr.ac.pusan.pickle.common.error.ApiException; import kr.ac.pusan.pickle.resource.VmResourceAdapter; import kr.ac.pusan.pickle.security.AuthenticatedUser; @@ -40,14 +41,14 @@ public VmAccessService(VmRepository vmRepository, ResourceAccessResolver resolve /** Standing of {@code actor} on {@code vmId}; unknown VM answers 404. */ @Transactional(readOnly = true) - public VmAccess of(AuthenticatedUser actor, long vmId) { + public VmAccess of(AuthenticatedUser actor, UUID vmId) { return of(vmId, actor.id()); } /** Standing of one user on {@code vmId}; unknown VM answers 404. */ @Transactional(readOnly = true) - public VmAccess of(long vmId, long userId) { - Vm vm = vmRepository.findById(vmId).orElseThrow(VmAccessService::vmNotFound); + public VmAccess of(UUID vmId, long userId) { + Vm vm = vmRepository.findByPublicId(vmId).orElseThrow(VmAccessService::vmNotFound); return of(vm, userId); } diff --git a/src/main/java/kr/ac/pusan/pickle/access/dto/AddResourceAccessGrantRequest.java b/src/main/java/kr/ac/pusan/pickle/access/dto/AddResourceAccessGrantRequest.java index 3ce13b74..4e16e6d0 100644 --- a/src/main/java/kr/ac/pusan/pickle/access/dto/AddResourceAccessGrantRequest.java +++ b/src/main/java/kr/ac/pusan/pickle/access/dto/AddResourceAccessGrantRequest.java @@ -2,6 +2,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; +import java.util.UUID; import kr.ac.pusan.pickle.access.AccessGranteeType; import kr.ac.pusan.pickle.access.ResourceRole; import org.jspecify.annotations.Nullable; @@ -11,7 +12,7 @@ public record AddResourceAccessGrantRequest( @Schema(description = "USER는 지정된 사용자 한 명, WORKSPACE은 소유 워크스페이스 전체") @NotNull AccessGranteeType granteeType, @Schema(description = "대상 사용자 id. granteeType이 USER일 때만 보내며, 소유 워크스페이스의 구성원이어야 합니다.") - @Nullable Long userId, + @Nullable UUID userId, @Schema(description = "부여할 등급. 워크스페이스 전체 항목에는 MEMBER 또는 VIEWER만 지정할 수 있습니다.") @NotNull ResourceRole role) { } diff --git a/src/main/java/kr/ac/pusan/pickle/access/dto/ResourceAccessGrantView.java b/src/main/java/kr/ac/pusan/pickle/access/dto/ResourceAccessGrantView.java index 41f14bac..75405601 100644 --- a/src/main/java/kr/ac/pusan/pickle/access/dto/ResourceAccessGrantView.java +++ b/src/main/java/kr/ac/pusan/pickle/access/dto/ResourceAccessGrantView.java @@ -2,6 +2,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import java.time.Instant; +import java.util.UUID; import kr.ac.pusan.pickle.access.AccessGranteeType; import kr.ac.pusan.pickle.access.ResourceAccessGrant; import kr.ac.pusan.pickle.access.ResourceRole; @@ -11,7 +12,7 @@ /** One entry in a VM's access list. */ @Schema(description = "VM 접근 권한 한 건. 대상은 지정된 사용자 한 명이거나 소유 워크스페이스 전체입니다.") public record ResourceAccessGrantView( - Long id, + UUID id, @Schema(description = "대상 종류 — USER는 지정된 사용자, WORKSPACE은 소유 워크스페이스 전체") AccessGranteeType granteeType, @Schema(description = "대상 사용자. 워크스페이스 전체 항목이면 null입니다.") @@ -21,13 +22,13 @@ public record ResourceAccessGrantView( Instant createdAt) { @Schema(description = "접근 권한을 가진 사용자") - public record Grantee(Long userId, String name, String email) { + public record Grantee(UUID userId, String name, String email) { } public static ResourceAccessGrantView of(ResourceAccessGrant grant, @Nullable User user) { Grantee grantee = user == null ? null - : new Grantee(user.getId(), user.getName(), user.getEmail()); - return new ResourceAccessGrantView(grant.getId(), grant.getGranteeType(), grantee, + : new Grantee(user.getPublicId(), user.getName(), user.getEmail()); + return new ResourceAccessGrantView(grant.getPublicId(), grant.getGranteeType(), grantee, grant.getRole(), grant.getCreatedAt()); } } diff --git a/src/main/java/kr/ac/pusan/pickle/access/dto/ResourceAccessListResponse.java b/src/main/java/kr/ac/pusan/pickle/access/dto/ResourceAccessListResponse.java index fa801462..c9508d8d 100644 --- a/src/main/java/kr/ac/pusan/pickle/access/dto/ResourceAccessListResponse.java +++ b/src/main/java/kr/ac/pusan/pickle/access/dto/ResourceAccessListResponse.java @@ -2,6 +2,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import java.util.List; +import java.util.UUID; import kr.ac.pusan.pickle.access.ResourceType; import kr.ac.pusan.pickle.resource.ResourceIdentity; import org.jspecify.annotations.Nullable; @@ -29,22 +30,22 @@ public record ResourceAccessListResponse( /** Name, state and owning workspace — the same fields a restricted row shows. */ @Schema(description = "접근 권한이 없는 사람에게도 보이는 범위의 리소스 정보") public record ResourceBrief( - Long id, + UUID id, ResourceType type, String name, @Schema(description = "표시명. 지정되지 않았으면 null입니다.") @Nullable String displayName, @Schema(description = "리소스 종류의 상태값. 종류마다 어휘가 다릅니다.") String status, - Long workspaceId, + UUID workspaceId, String workspaceName) { } public static ResourceAccessListResponse of(ResourceType type, ResourceIdentity resource, - String workspaceName, List grants) { + UUID workspaceId, String workspaceName, List grants) { return new ResourceAccessListResponse( - new ResourceBrief(resource.id(), type, resource.name(), resource.displayName(), - resource.status(), resource.workspaceId(), workspaceName), + new ResourceBrief(resource.publicId(), type, resource.name(), resource.displayName(), + resource.status(), workspaceId, workspaceName), grants); } } diff --git a/src/main/java/kr/ac/pusan/pickle/admin/AdminController.java b/src/main/java/kr/ac/pusan/pickle/admin/AdminController.java index d0d2e187..69f73c2e 100644 --- a/src/main/java/kr/ac/pusan/pickle/admin/AdminController.java +++ b/src/main/java/kr/ac/pusan/pickle/admin/AdminController.java @@ -5,6 +5,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import java.util.List; +import java.util.UUID; import kr.ac.pusan.pickle.admin.dto.CreateOrgRequest; import kr.ac.pusan.pickle.admin.dto.OrgDetailResponse; import kr.ac.pusan.pickle.admin.dto.UpdateOrgRequest; @@ -62,7 +63,7 @@ public OrgDetailResponse createOrg( @PatchMapping("/orgs/{orgId}") public OrgDetailResponse updateOrg(@AuthenticationPrincipal AuthenticatedUser principal, - @PathVariable long orgId, + @PathVariable UUID orgId, @Valid @RequestBody UpdateOrgRequest request, HttpServletRequest httpRequest) { return adminService.updateOrg(principal, orgId, request, clientIp(httpRequest)); @@ -70,7 +71,7 @@ public OrgDetailResponse updateOrg(@AuthenticationPrincipal AuthenticatedUser pr @PatchMapping("/users/{userId}") public UserSummaryResponse updateUser(@AuthenticationPrincipal AuthenticatedUser principal, - @PathVariable long userId, + @PathVariable UUID userId, @Valid @RequestBody UpdateUserAdminRequest request, HttpServletRequest httpRequest) { return adminService.updateUser(principal, userId, request, clientIp(httpRequest)); diff --git a/src/main/java/kr/ac/pusan/pickle/admin/AdminDriftController.java b/src/main/java/kr/ac/pusan/pickle/admin/AdminDriftController.java index adc7f23f..f1dfecfb 100644 --- a/src/main/java/kr/ac/pusan/pickle/admin/AdminDriftController.java +++ b/src/main/java/kr/ac/pusan/pickle/admin/AdminDriftController.java @@ -6,6 +6,7 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; +import java.util.UUID; import kr.ac.pusan.pickle.admin.dto.DriftFindingResponse; import kr.ac.pusan.pickle.admin.dto.ResolveDriftFindingRequest; import kr.ac.pusan.pickle.common.web.PageResponse; @@ -49,7 +50,7 @@ public PageResponse listDriftFindings( @PostMapping("/{findingId}/resolve") public DriftFindingResponse resolveDriftFinding( @AuthenticationPrincipal AuthenticatedUser principal, - @PathVariable long findingId, + @PathVariable UUID findingId, @Valid @RequestBody(required = false) ResolveDriftFindingRequest request, HttpServletRequest httpRequest) { return adminDriftService.resolve(principal, findingId, request, clientIp(httpRequest)); diff --git a/src/main/java/kr/ac/pusan/pickle/admin/AdminDriftService.java b/src/main/java/kr/ac/pusan/pickle/admin/AdminDriftService.java index e4e93237..48ff63b9 100644 --- a/src/main/java/kr/ac/pusan/pickle/admin/AdminDriftService.java +++ b/src/main/java/kr/ac/pusan/pickle/admin/AdminDriftService.java @@ -4,6 +4,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.UUID; import java.util.stream.Collectors; import kr.ac.pusan.pickle.admin.dto.DriftFindingResponse; import kr.ac.pusan.pickle.admin.dto.ResolveDriftFindingRequest; @@ -67,21 +68,29 @@ public PageResponse list(DriftFindingStatus status, DriftF Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Order.desc("lastSeenAt"), Sort.Order.desc("id"))); Page result = driftFindingRepository.findAll(spec, pageable); - Map emails = emailsById(result.getContent()); - Map vmNames = vmNamesById(result.getContent()); + Map resolvers = resolversById(result.getContent()); + Map vms = vmsById(result.getContent()); List content = result.getContent().stream() - .map(finding -> toResponse(finding, finding.getResolvedBy() == null ? null - : emails.get(finding.getResolvedBy()), - finding.getVmId() == null ? null : vmNames.get(finding.getVmId()))) + .map(finding -> { + Vm vm = finding.getVmId() == null ? null : vms.get(finding.getVmId()); + User resolver = finding.getResolvedBy() == null ? null + : resolvers.get(finding.getResolvedBy()); + return toResponse(finding, vm == null ? null : vm.getPublicId(), + vm == null ? null : vm.getName(), + resolver == null ? null : resolver.getPublicId(), + resolver == null ? null : resolver.getEmail()); + }) .toList(); return PageResponse.of(content, result); } /** Contract {@code resolveDriftFinding}: CAS OPEN→RESOLVED with the acting admin. */ @Transactional - public DriftFindingResponse resolve(AuthenticatedUser actor, long findingId, + public DriftFindingResponse resolve(AuthenticatedUser actor, UUID publicFindingId, ResolveDriftFindingRequest request, String ip) { String note = request == null ? null : request.note(); + long findingId = driftFindingRepository.findByPublicId(publicFindingId) + .map(DriftFinding::getId).orElse(-1L); if (driftFindingRepository.resolve(findingId, actor.id(), note, Instant.now()) == 0) { driftFindingRepository.findById(findingId) .orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, @@ -92,16 +101,17 @@ public DriftFindingResponse resolve(AuthenticatedUser actor, long findingId, } DriftFinding finding = driftFindingRepository.findById(findingId).orElseThrow(); auditService.recordAfterCommit(actor.id(), actor.role().name(), AuditService.DRIFT_RESOLVE, - "drift_finding", findingId, + "drift_finding", finding.getPublicId(), note == null ? Map.of("kind", finding.getKind().name()) : Map.of("kind", finding.getKind().name(), "note", note), ip); String vmName = finding.getVmId() == null ? null : vmRepository.findById(finding.getVmId()).map(Vm::getName).orElse(null); - return toResponse(finding, actor.email(), vmName); + return toResponse(finding, vmPublicId(finding.getVmId()), vmName, + actor.publicId(), actor.email()); } - private Map emailsById(List findings) { + private Map resolversById(List findings) { List ids = findings.stream() .map(DriftFinding::getResolvedBy) .filter(Objects::nonNull) @@ -109,11 +119,11 @@ private Map emailsById(List findings) { .toList(); return ids.isEmpty() ? Map.of() : userRepository.findAllById(ids).stream() - .collect(Collectors.toMap(User::getId, User::getEmail)); + .collect(Collectors.toMap(User::getId, java.util.function.Function.identity())); } /** VM-name join (v0.9.0 display field); null vmId (UNMANAGED_GUEST) → no entry. */ - private Map vmNamesById(List findings) { + private Map vmsById(List findings) { List ids = findings.stream() .map(DriftFinding::getVmId) .filter(Objects::nonNull) @@ -121,13 +131,18 @@ private Map vmNamesById(List findings) { .toList(); return ids.isEmpty() ? Map.of() : vmRepository.findAllById(ids).stream() - .collect(Collectors.toMap(Vm::getId, Vm::getName)); + .collect(Collectors.toMap(Vm::getId, java.util.function.Function.identity())); } - private DriftFindingResponse toResponse(DriftFinding finding, String resolvedByEmail, - String vmName) { + private DriftFindingResponse toResponse(DriftFinding finding, UUID vmId, String vmName, + UUID resolvedById, String resolvedByEmail) { JsonNode detail = finding.getDetail() == null ? null : objectMapper.readTree(finding.getDetail()); - return DriftFindingResponse.from(finding, detail, resolvedByEmail, vmName); + return DriftFindingResponse.from(finding, detail, vmId, vmName, resolvedById, resolvedByEmail); + } + + private UUID vmPublicId(Long vmId) { + return vmId == null ? null + : vmRepository.findById(vmId).map(Vm::getPublicId).orElse(null); } } diff --git a/src/main/java/kr/ac/pusan/pickle/admin/AdminInventoryService.java b/src/main/java/kr/ac/pusan/pickle/admin/AdminInventoryService.java index a75c3d7e..c5e071fb 100644 --- a/src/main/java/kr/ac/pusan/pickle/admin/AdminInventoryService.java +++ b/src/main/java/kr/ac/pusan/pickle/admin/AdminInventoryService.java @@ -4,6 +4,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.UUID; import kr.ac.pusan.pickle.admin.dto.AdminOsImageResponse; import kr.ac.pusan.pickle.admin.dto.CreateVmFlavorRequest; import kr.ac.pusan.pickle.admin.dto.NodeSummaryResponse; @@ -63,37 +64,49 @@ public AdminInventoryService(OsImageRepository osImageRepository, /** Contract {@code listAdminOsImages}: every OS image, retired revisions included. */ @Transactional(readOnly = true) public List listOsImages() { - return osImageRepository.findAllInDisplayOrder().stream() - .map(AdminOsImageResponse::from) + List images = osImageRepository.findAllInDisplayOrder(); + // The image names its node by public id, so the node rows are batched. + Map nodeIds = nodePublicIds(images); + return images.stream() + .map(image -> AdminOsImageResponse.from(image, nodeIds.get(image.getNodeId()))) .toList(); } + private Map nodePublicIds(List images) { + List ids = images.stream().map(OsImage::getNodeId) + .filter(java.util.Objects::nonNull).distinct().toList(); + return ids.isEmpty() ? Map.of() + : nodeRepository.findAllById(ids).stream() + .collect(java.util.stream.Collectors.toMap(Node::getId, Node::getPublicId)); + } + @Transactional - public AdminOsImageResponse updateCatalogStatus(AuthenticatedUser actor, long imageId, + public AdminOsImageResponse updateCatalogStatus(AuthenticatedUser actor, UUID imageId, UpdateOsImageStatusRequest request, String ip) { - OsImage image = osImageRepository.findById(imageId) + OsImage image = osImageRepository.findByPublicId(imageId) .orElseThrow(() -> notFound("해당 OS 이미지가 존재하지 않습니다.")); if (image.getStatus() != request.status()) { String fromStatus = image.getStatus().name(); image.setStatus(request.status()); auditService.recordAfterCommit(actor.id(), actor.role().name(), - AuditService.OS_IMAGE_STATUS_UPDATE, "os_image", imageId, + AuditService.OS_IMAGE_STATUS_UPDATE, "os_image", image.getPublicId(), Map.of("name", image.getName(), "version", image.getVersion(), "fromStatus", fromStatus, "toStatus", request.status().name()), ip); } - return AdminOsImageResponse.from(image); + return AdminOsImageResponse.from(image, nodePublicIds(List.of(image)).get(image.getNodeId())); } @Transactional - public NodeSummaryResponse updateNodeStatus(AuthenticatedUser actor, long nodeId, + public NodeSummaryResponse updateNodeStatus(AuthenticatedUser actor, UUID publicNodeId, UpdateNodeStatusRequest request, String ip) { - Node node = nodeRepository.findById(nodeId) + Node node = nodeRepository.findByPublicId(publicNodeId) .orElseThrow(() -> notFound("해당 노드가 존재하지 않습니다.")); + long nodeId = node.getId(); if (node.getStatus() != request.status()) { String fromStatus = node.getStatus().name(); node.setStatus(request.status()); auditService.recordAfterCommit(actor.id(), actor.role().name(), - AuditService.NODE_STATUS_UPDATE, "node", nodeId, + AuditService.NODE_STATUS_UPDATE, "node", node.getPublicId(), Map.of("name", node.getName(), "fromStatus", fromStatus, "toStatus", request.status().name()), ip); } @@ -127,7 +140,7 @@ public VmFlavorResponse createFlavor(AuthenticatedUser actor, CreateVmFlavorRequ throw nameTaken(); } auditService.recordAfterCommit(actor.id(), actor.role().name(), AuditService.FLAVOR_CREATE, - "vm_flavor", flavor.getId(), + "vm_flavor", flavor.getPublicId(), Map.of("name", flavor.getName(), "vcpu", flavor.getVcpu(), "memoryMb", flavor.getMemoryMb(), "diskGb", flavor.getDiskGb()), ip); return VmFlavorResponse.from(flavor); @@ -138,9 +151,9 @@ public VmFlavorResponse createFlavor(AuthenticatedUser actor, CreateVmFlavorRequ * idempotent re-application without an audit row, like the status toggles. */ @Transactional - public VmFlavorResponse updateFlavor(AuthenticatedUser actor, long flavorId, + public VmFlavorResponse updateFlavor(AuthenticatedUser actor, UUID flavorId, UpdateVmFlavorRequest request, String ip) { - VmFlavor flavor = vmFlavorRepository.findById(flavorId) + VmFlavor flavor = vmFlavorRepository.findByPublicId(flavorId) .orElseThrow(() -> notFound("해당 사양 프리셋이 존재하지 않습니다.")); if (request.displayName() == null && request.vcpu() == null && request.memoryMb() == null && request.diskGb() == null && request.notes() == null && request.status() == null) { @@ -179,7 +192,7 @@ public VmFlavorResponse updateFlavor(AuthenticatedUser actor, long flavorId, if (!changes.isEmpty()) { changes.put("name", flavor.getName()); auditService.recordAfterCommit(actor.id(), actor.role().name(), - AuditService.FLAVOR_UPDATE, "vm_flavor", flavorId, changes, ip); + AuditService.FLAVOR_UPDATE, "vm_flavor", flavor.getPublicId(), changes, ip); } return VmFlavorResponse.from(flavor); } diff --git a/src/main/java/kr/ac/pusan/pickle/admin/AdminIpAllocationController.java b/src/main/java/kr/ac/pusan/pickle/admin/AdminIpAllocationController.java index 6140c8a1..a02c4d6e 100644 --- a/src/main/java/kr/ac/pusan/pickle/admin/AdminIpAllocationController.java +++ b/src/main/java/kr/ac/pusan/pickle/admin/AdminIpAllocationController.java @@ -5,6 +5,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.UUID; import java.util.function.Function; import java.util.stream.Collectors; import kr.ac.pusan.pickle.admin.dto.IpAllocationResponse; @@ -50,13 +51,16 @@ public AdminIpAllocationController(IpAllocationRepository ipAllocationRepository @GetMapping public PageResponse listIpAllocations( - @RequestParam(required = false) Long poolId, + @RequestParam(required = false) UUID poolId, @RequestParam(required = false) AllocationStatus status, @RequestParam(defaultValue = "0") @Min(0) int page, @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) { Specification spec = (root, query, cb) -> cb.conjunction(); if (poolId != null) { - spec = spec.and((root, query, cb) -> cb.equal(root.get("poolId"), poolId)); + // An id no pool has filters to nothing, as a non-matching number did. + Long scopedPoolId = ipPoolRepository.findByPublicId(poolId).map(IpPool::getId) + .orElse(-1L); + spec = spec.and((root, query, cb) -> cb.equal(root.get("poolId"), scopedPoolId)); } if (status != null) { spec = spec.and((root, query, cb) -> cb.equal(root.get("status"), status)); @@ -65,18 +69,23 @@ public PageResponse listIpAllocations( Sort.by(Sort.Order.desc("allocatedAt"), Sort.Order.desc("id"))); Page result = ipAllocationRepository.findAll(spec, pageable); - Map poolNames = ipPoolRepository.findAllById(result.getContent().stream() + Map pools = ipPoolRepository.findAllById(result.getContent().stream() .map(IpAllocation::getPoolId).distinct().toList()).stream() - .collect(Collectors.toMap(IpPool::getId, IpPool::getName)); + .collect(Collectors.toMap(IpPool::getId, Function.identity())); Map vms = vmRepository.findAllById(result.getContent().stream() .map(IpAllocation::getVmId).filter(Objects::nonNull).distinct().toList()) .stream() .collect(Collectors.toMap(Vm::getId, Function.identity())); List content = result.getContent().stream() - .map(allocation -> IpAllocationResponse.from(allocation, - poolNames.get(allocation.getPoolId()), - allocation.getVmId() == null ? null : vms.get(allocation.getVmId()))) + .map(allocation -> { + IpPool pool = pools.get(allocation.getPoolId()); + Vm vm = allocation.getVmId() == null ? null : vms.get(allocation.getVmId()); + return IpAllocationResponse.from(allocation, + pool == null ? null : pool.getPublicId(), + pool == null ? null : pool.getName(), + vm == null ? null : vm.getPublicId(), vm); + }) .toList(); return PageResponse.of(content, result); } diff --git a/src/main/java/kr/ac/pusan/pickle/admin/AdminNodeController.java b/src/main/java/kr/ac/pusan/pickle/admin/AdminNodeController.java index 9d07d40d..871906d4 100644 --- a/src/main/java/kr/ac/pusan/pickle/admin/AdminNodeController.java +++ b/src/main/java/kr/ac/pusan/pickle/admin/AdminNodeController.java @@ -6,6 +6,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import java.util.List; +import java.util.UUID; import kr.ac.pusan.pickle.admin.dto.NodeMetricsResponse; import kr.ac.pusan.pickle.admin.dto.NodeSummaryResponse; import kr.ac.pusan.pickle.admin.dto.UpdateNodeStatusRequest; @@ -52,7 +53,7 @@ public List listAdminNodes() { /** Live host usage series from the hypervisor (contract v0.35.0). */ @GetMapping("/{nodeId}/metrics") - public NodeMetricsResponse getAdminNodeMetrics(@PathVariable long nodeId, + public NodeMetricsResponse getAdminNodeMetrics(@PathVariable UUID nodeId, @Parameter(description = "조회 구간 — HOUR/DAY/WEEK/MONTH/YEAR " + "(해상도는 구간에 따라 거칠어짐)") @RequestParam(defaultValue = "HOUR") RrdTimeframe timeframe) { @@ -63,7 +64,7 @@ public NodeMetricsResponse getAdminNodeMetrics(@PathVariable long nodeId, @PreAuthorize("hasRole('SYS_ADMIN')") public NodeSummaryResponse updateAdminNode( @AuthenticationPrincipal AuthenticatedUser principal, - @PathVariable long nodeId, + @PathVariable UUID nodeId, @Valid @RequestBody UpdateNodeStatusRequest request, HttpServletRequest httpRequest) { return adminInventoryService.updateNodeStatus(principal, nodeId, request, diff --git a/src/main/java/kr/ac/pusan/pickle/admin/AdminNodeMetricsService.java b/src/main/java/kr/ac/pusan/pickle/admin/AdminNodeMetricsService.java index 130f5414..857523f7 100644 --- a/src/main/java/kr/ac/pusan/pickle/admin/AdminNodeMetricsService.java +++ b/src/main/java/kr/ac/pusan/pickle/admin/AdminNodeMetricsService.java @@ -2,6 +2,7 @@ import java.time.Clock; import java.util.List; +import java.util.UUID; import kr.ac.pusan.pickle.admin.dto.NodeMetricPointResponse; import kr.ac.pusan.pickle.admin.dto.NodeMetricsResponse; import kr.ac.pusan.pickle.common.error.ApiException; @@ -44,8 +45,8 @@ public AdminNodeMetricsService(NodeRepository nodeRepository, ProxmoxClient prox this.clock = clock; } - public NodeMetricsResponse metrics(long nodeId, RrdTimeframe timeframe) { - Node node = nodeRepository.findById(nodeId).orElseThrow(() -> new ApiException( + public NodeMetricsResponse metrics(UUID nodeId, RrdTimeframe timeframe) { + Node node = nodeRepository.findByPublicId(nodeId).orElseThrow(() -> new ApiException( HttpStatus.NOT_FOUND, ErrorCodes.RESOURCE_NOT_FOUND, "리소스를 찾을 수 없습니다", "해당 노드가 존재하지 않습니다.")); List points; diff --git a/src/main/java/kr/ac/pusan/pickle/admin/AdminNodeQueryService.java b/src/main/java/kr/ac/pusan/pickle/admin/AdminNodeQueryService.java index 78663a02..09810c6b 100644 --- a/src/main/java/kr/ac/pusan/pickle/admin/AdminNodeQueryService.java +++ b/src/main/java/kr/ac/pusan/pickle/admin/AdminNodeQueryService.java @@ -85,7 +85,7 @@ private NodeSummaryResponse toSummary(Node node, NodeAllocation allocation, doub : round2((double) allocation.vcpu() / node.getCpuThreads()); double memoryRatio = node.getMemoryMb() == 0 ? 0.0 : round2((double) allocation.memoryMb() / node.getMemoryMb()); - return new NodeSummaryResponse(node.getId(), node.getName(), node.getStatus(), + return new NodeSummaryResponse(node.getPublicId(), node.getName(), node.getStatus(), node.getCpuThreads(), node.getMemoryMb(), node.getVmBridge(), node.getStorage(), node.getDiskCapacityGb(), allocation.running(), allocation.vcpu(), allocation.memoryMb(), diff --git a/src/main/java/kr/ac/pusan/pickle/admin/AdminNotificationController.java b/src/main/java/kr/ac/pusan/pickle/admin/AdminNotificationController.java index d9918aef..ca4a4fab 100644 --- a/src/main/java/kr/ac/pusan/pickle/admin/AdminNotificationController.java +++ b/src/main/java/kr/ac/pusan/pickle/admin/AdminNotificationController.java @@ -5,6 +5,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; +import java.util.UUID; import kr.ac.pusan.pickle.admin.dto.AdminNotificationResponse; import kr.ac.pusan.pickle.auth.dto.MessageResponse; import kr.ac.pusan.pickle.common.web.PageResponse; @@ -51,7 +52,7 @@ public PageResponse listAdminNotifications( @ResponseStatus(HttpStatus.ACCEPTED) public MessageResponse resendAdminNotification( @AuthenticationPrincipal AuthenticatedUser principal, - @PathVariable long notificationId, + @PathVariable UUID notificationId, HttpServletRequest httpRequest) { return adminNotificationService.resend(principal, notificationId, clientIp(httpRequest)); diff --git a/src/main/java/kr/ac/pusan/pickle/admin/AdminNotificationService.java b/src/main/java/kr/ac/pusan/pickle/admin/AdminNotificationService.java index c98ce29f..a0b4aa98 100644 --- a/src/main/java/kr/ac/pusan/pickle/admin/AdminNotificationService.java +++ b/src/main/java/kr/ac/pusan/pickle/admin/AdminNotificationService.java @@ -5,6 +5,7 @@ import java.sql.Timestamp; import java.util.List; import java.util.Map; +import java.util.UUID; import kr.ac.pusan.pickle.admin.dto.AdminNotificationResponse; import kr.ac.pusan.pickle.audit.AuditService; import kr.ac.pusan.pickle.auth.dto.MessageResponse; @@ -54,8 +55,10 @@ public PageResponse list(NotificationStatus status, S "select count(*) from notifications n join users u on u.id = n.user_id" + BASE_WHERE, Long.class, filters); List content = jdbcTemplate.query(""" - select n.id, n.event, n.title, n.body, n.link_path, n.importance, n.created_at, - n.read_at, n.user_id, u.email, n.channel::text as channel, + select n.public_id, n.event, n.title, n.body, n.link_path, n.importance, + n.created_at, + n.read_at, u.public_id as user_public_id, u.email, + n.channel::text as channel, n.status::text as status, n.attempts, n.last_error, n.sent_at from notifications n join users u on u.id = n.user_id @@ -70,15 +73,16 @@ public PageResponse list(NotificationStatus status, S /** CAS FAILED→PENDING due now; anything else answers 409 (404 when absent). */ @Transactional - public MessageResponse resend(AuthenticatedUser actor, long notificationId, String ip) { + public MessageResponse resend(AuthenticatedUser actor, UUID notificationId, String ip) { int updated = jdbcTemplate.update(""" update notifications set status = 'PENDING', next_attempt_at = now() - where id = ? and status = 'FAILED' + where public_id = ? and status = 'FAILED' """, notificationId); if (updated == 0) { Long found = jdbcTemplate.queryForObject( - "select count(*) from notifications where id = ?", Long.class, notificationId); + "select count(*) from notifications where public_id = ?", Long.class, + notificationId); if (found == null || found == 0) { throw new ApiException(HttpStatus.NOT_FOUND, ErrorCodes.RESOURCE_NOT_FOUND, "리소스를 찾을 수 없습니다", "해당 알림이 존재하지 않습니다."); @@ -93,7 +97,7 @@ public MessageResponse resend(AuthenticatedUser actor, long notificationId, Stri private static AdminNotificationResponse mapRow(ResultSet rs, int rowNum) throws SQLException { return new AdminNotificationResponse( - rs.getLong("id"), + rs.getObject("public_id", java.util.UUID.class), rs.getString("event"), rs.getString("title"), rs.getString("body"), @@ -101,7 +105,7 @@ private static AdminNotificationResponse mapRow(ResultSet rs, int rowNum) throws NotificationImportance.valueOf(rs.getString("importance")), instant(rs.getTimestamp("created_at")), instant(rs.getTimestamp("read_at")), - rs.getLong("user_id"), + rs.getObject("user_public_id", java.util.UUID.class), rs.getString("email"), NotificationChannel.valueOf(rs.getString("channel")), NotificationStatus.valueOf(rs.getString("status")), diff --git a/src/main/java/kr/ac/pusan/pickle/admin/AdminOsImageController.java b/src/main/java/kr/ac/pusan/pickle/admin/AdminOsImageController.java index c588e1f6..eb84a1ec 100644 --- a/src/main/java/kr/ac/pusan/pickle/admin/AdminOsImageController.java +++ b/src/main/java/kr/ac/pusan/pickle/admin/AdminOsImageController.java @@ -5,6 +5,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import java.util.List; +import java.util.UUID; import kr.ac.pusan.pickle.admin.dto.AdminOsImageResponse; import kr.ac.pusan.pickle.admin.dto.UpdateOsImageStatusRequest; import kr.ac.pusan.pickle.security.AuthenticatedUser; @@ -43,7 +44,7 @@ public List listAdminOsImages() { @PreAuthorize("hasRole('SYS_ADMIN')") public AdminOsImageResponse updateAdminOsImage( @AuthenticationPrincipal AuthenticatedUser principal, - @PathVariable long imageId, + @PathVariable UUID imageId, @Valid @RequestBody UpdateOsImageStatusRequest request, HttpServletRequest httpRequest) { return adminInventoryService.updateCatalogStatus(principal, imageId, request, diff --git a/src/main/java/kr/ac/pusan/pickle/admin/AdminRequestController.java b/src/main/java/kr/ac/pusan/pickle/admin/AdminRequestController.java index 2f7cff58..0668f406 100644 --- a/src/main/java/kr/ac/pusan/pickle/admin/AdminRequestController.java +++ b/src/main/java/kr/ac/pusan/pickle/admin/AdminRequestController.java @@ -6,6 +6,7 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; +import java.util.UUID; import kr.ac.pusan.pickle.admin.dto.ApprovalContextResponse; import kr.ac.pusan.pickle.admin.dto.ApproveRequestRequest; import kr.ac.pusan.pickle.admin.dto.RejectRequestRequest; @@ -50,7 +51,7 @@ public PageResponse listAdminRequests( @AuthenticationPrincipal AuthenticatedUser principal, @RequestParam(required = false) RequestStatus status, @RequestParam(required = false) ResourceType type, - @RequestParam(required = false) Long orgId, + @RequestParam(required = false) UUID orgId, @RequestParam(defaultValue = "0") @Min(0) int page, @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) { return approvalService.list(principal, status, type, orgId, page, size); @@ -58,20 +59,20 @@ public PageResponse listAdminRequests( @GetMapping("/{requestId}") public RequestDetailResponse getAdminRequest(@AuthenticationPrincipal AuthenticatedUser principal, - @PathVariable long requestId) { + @PathVariable UUID requestId) { return approvalService.get(principal, requestId); } @GetMapping("/{requestId}/context") public ApprovalContextResponse getApprovalContext(@AuthenticationPrincipal AuthenticatedUser principal, - @PathVariable long requestId) { + @PathVariable UUID requestId) { return approvalContextService.context(principal, requestId); } @PostMapping("/{requestId}/approve") @PreAuthorize("hasAnyRole('ORG_ADMIN', 'ORG_MANAGER', 'SYS_ADMIN')") public RequestDetailResponse approveRequest(@AuthenticationPrincipal AuthenticatedUser principal, - @PathVariable long requestId, + @PathVariable UUID requestId, @Valid @RequestBody ApproveRequestRequest request, HttpServletRequest httpRequest) { return approvalService.approve(principal, requestId, request, clientIp(httpRequest)); @@ -80,7 +81,7 @@ public RequestDetailResponse approveRequest(@AuthenticationPrincipal Authenticat @PostMapping("/{requestId}/reject") @PreAuthorize("hasAnyRole('ORG_ADMIN', 'ORG_MANAGER', 'SYS_ADMIN')") public RequestDetailResponse rejectRequest(@AuthenticationPrincipal AuthenticatedUser principal, - @PathVariable long requestId, + @PathVariable UUID requestId, @Valid @RequestBody RejectRequestRequest request, HttpServletRequest httpRequest) { return approvalService.reject(principal, requestId, request, clientIp(httpRequest)); diff --git a/src/main/java/kr/ac/pusan/pickle/admin/AdminService.java b/src/main/java/kr/ac/pusan/pickle/admin/AdminService.java index 1458e3e1..586147c4 100644 --- a/src/main/java/kr/ac/pusan/pickle/admin/AdminService.java +++ b/src/main/java/kr/ac/pusan/pickle/admin/AdminService.java @@ -2,6 +2,7 @@ import java.util.List; import java.util.Map; +import java.util.UUID; import kr.ac.pusan.pickle.admin.dto.CreateOrgRequest; import kr.ac.pusan.pickle.admin.dto.OrgDetailResponse; import kr.ac.pusan.pickle.admin.dto.UpdateOrgRequest; @@ -18,7 +19,6 @@ import kr.ac.pusan.pickle.user.User; import kr.ac.pusan.pickle.user.UserRepository; import kr.ac.pusan.pickle.user.UserRole; -import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -51,24 +51,18 @@ public java.util.List listOrgs() { @Transactional public OrgDetailResponse createOrg(AuthenticatedUser actor, CreateOrgRequest request, String ip) { - if (orgRepository.existsBySlug(request.slug())) { - throw orgSlugDuplicate(request.slug()); - } - Org org; - try { - org = orgRepository.save(new Org(request.name().strip(), request.slug(), - normalize(request.description()))); - } catch (DataIntegrityViolationException raceWithConcurrentCreate) { - throw orgSlugDuplicate(request.slug()); - } + // No duplicate check: the org slug carried the only uniqueness constraint + // this table ever had, and V78 dropped it. Two orgs may share a name. + Org org = orgRepository.save(new Org(request.name().strip(), + normalize(request.description()))); auditService.recordAfterCommit(actor.id(), actor.role().name(), AuditService.ORG_CREATE, - "org", org.getId(), Map.of("name", org.getName(), "slug", org.getSlug()), ip); + "org", org.getPublicId(), Map.of("name", org.getName()), ip); return OrgDetailResponse.from(org); } @Transactional - public OrgDetailResponse updateOrg(AuthenticatedUser actor, long orgId, UpdateOrgRequest request, String ip) { - Org org = orgRepository.findById(orgId) + public OrgDetailResponse updateOrg(AuthenticatedUser actor, UUID orgId, UpdateOrgRequest request, String ip) { + Org org = orgRepository.findByPublicId(orgId) .orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, ErrorCodes.RESOURCE_NOT_FOUND, "리소스를 찾을 수 없습니다", "해당 기관이 존재하지 않습니다.")); if (request.isEmpty()) { @@ -100,7 +94,7 @@ public OrgDetailResponse updateOrg(AuthenticatedUser actor, long orgId, UpdateOr org.setHidden(request.getHidden()); } auditService.recordAfterCommit(actor.id(), actor.role().name(), AuditService.ORG_UPDATE, - "org", org.getId(), Map.of("name", org.getName(), "status", org.getStatus().name(), + "org", org.getPublicId(), Map.of("name", org.getName(), "status", org.getStatus().name(), "hidden", org.isHidden()), ip); return OrgDetailResponse.from(org); } @@ -110,9 +104,9 @@ public OrgDetailResponse updateOrg(AuthenticatedUser actor, long orgId, UpdateOr * outstanding access tokens of the target user become invalid immediately. */ @Transactional - public UserSummaryResponse updateUser(AuthenticatedUser actor, long userId, + public UserSummaryResponse updateUser(AuthenticatedUser actor, UUID userId, UpdateUserAdminRequest request, String ip) { - User user = userRepository.findById(userId) + User user = userRepository.findByPublicId(userId) .orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, ErrorCodes.RESOURCE_NOT_FOUND, "사용자를 찾을 수 없습니다", "해당 ID의 사용자가 존재하지 않습니다.")); if (request.isEmpty()) { @@ -124,12 +118,14 @@ public UserSummaryResponse updateUser(AuthenticatedUser actor, long userId, UserRole targetRole = request.role() != null ? request.role() : user.getRole(); if (targetRole.isOrgTier()) { // ORG_ADMIN and ORG_MANAGER both manage one org (per the permission matrix). - Long orgId = request.orgId() != null ? request.orgId() : user.getOrgId(); - if (orgId == null) { + Long orgId = request.orgId() != null + ? orgRepository.findByPublicId(request.orgId()).map(Org::getId).orElse(null) + : user.getOrgId(); + if (request.orgId() == null && orgId == null) { throw ApiException.validationFailed(List.of(new FieldValidationError("orgId", "ORG_ADMIN·ORG_MANAGER 역할에는 관리 기관(orgId)을 지정해야 합니다."))); } - if (!orgRepository.existsById(orgId)) { + if (orgId == null) { throw ApiException.validationFailed(List.of(new FieldValidationError("orgId", "존재하지 않는 기관(orgId)입니다."))); } @@ -148,7 +144,7 @@ public UserSummaryResponse updateUser(AuthenticatedUser actor, long userId, } auditService.recordAfterCommit(actor.id(), actor.role().name(), AuditService.USER_ROLE_UPDATE, - "user", user.getId(), + "user", user.getPublicId(), Map.of("previousRole", previousRole.name(), "role", user.getRole().name(), "orgId", user.getOrgId() == null ? "null" : String.valueOf(user.getOrgId())), ip); return UserSummaryResponse.from(user); @@ -157,9 +153,4 @@ public UserSummaryResponse updateUser(AuthenticatedUser actor, long userId, private static String normalize(String description) { return Texts.blankToNull(description); } - - private static ApiException orgSlugDuplicate(String slug) { - return new ApiException(HttpStatus.CONFLICT, ErrorCodes.ORG_SLUG_DUPLICATE, - "이미 사용 중인 slug입니다", "'" + slug + "'은(는) 이미 다른 기관이 사용 중입니다."); - } } diff --git a/src/main/java/kr/ac/pusan/pickle/admin/AdminSummaryController.java b/src/main/java/kr/ac/pusan/pickle/admin/AdminSummaryController.java index 43dcc7e0..4ca4e52b 100644 --- a/src/main/java/kr/ac/pusan/pickle/admin/AdminSummaryController.java +++ b/src/main/java/kr/ac/pusan/pickle/admin/AdminSummaryController.java @@ -2,6 +2,7 @@ import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; +import java.util.UUID; import kr.ac.pusan.pickle.admin.dto.CapacityTrendResponse; import kr.ac.pusan.pickle.admin.dto.OrgDashboardSummaryResponse; import kr.ac.pusan.pickle.admin.dto.SystemDashboardSummaryResponse; @@ -36,7 +37,7 @@ public AdminSummaryController(AdminSummaryService adminSummaryService, @PreAuthorize("hasAnyRole('ORG_ADMIN', 'ORG_MANAGER', 'SYS_ADMIN', 'SYS_MANAGER')") public OrgDashboardSummaryResponse getAdminSummary( @AuthenticationPrincipal AuthenticatedUser principal, - @RequestParam(required = false) Long orgId) { + @RequestParam(required = false) UUID orgId) { return adminSummaryService.orgSummary(principal, orgId); } @@ -46,7 +47,7 @@ public OrgDashboardSummaryResponse getAdminSummary( public CapacityTrendResponse getAdminCapacityTrend( @AuthenticationPrincipal AuthenticatedUser principal, @RequestParam(defaultValue = "90") @Min(7) @Max(365) int days, - @RequestParam(required = false) Long orgId) { + @RequestParam(required = false) UUID orgId) { return capacityTrendService.trend(principal, days, orgId); } diff --git a/src/main/java/kr/ac/pusan/pickle/admin/AdminSummaryService.java b/src/main/java/kr/ac/pusan/pickle/admin/AdminSummaryService.java index 609dd935..ffaf75e2 100644 --- a/src/main/java/kr/ac/pusan/pickle/admin/AdminSummaryService.java +++ b/src/main/java/kr/ac/pusan/pickle/admin/AdminSummaryService.java @@ -8,6 +8,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.UUID; import kr.ac.pusan.pickle.admin.dto.NodeLiveResponse; import kr.ac.pusan.pickle.admin.dto.NodeSummaryResponse; import kr.ac.pusan.pickle.admin.dto.OrgDashboardSummaryResponse; @@ -27,6 +28,7 @@ import kr.ac.pusan.pickle.inventory.NodeRepository; import kr.ac.pusan.pickle.ipam.IpPoolRepository; import kr.ac.pusan.pickle.ipam.IpamService; +import kr.ac.pusan.pickle.orgs.Org; import kr.ac.pusan.pickle.orgs.OrgRepository; import kr.ac.pusan.pickle.provisioning.DriftFindingRepository; import kr.ac.pusan.pickle.provisioning.DriftFindingStatus; @@ -90,7 +92,7 @@ public AdminSummaryService(JdbcTemplate jdbcTemplate, OrgRepository orgRepositor * no org filter anywhere below; the console home calls it that way). */ @Transactional(readOnly = true) - public OrgDashboardSummaryResponse orgSummary(AuthenticatedUser actor, Long orgId) { + public OrgDashboardSummaryResponse orgSummary(AuthenticatedUser actor, UUID orgId) { Long scopedOrgId = resolveOrgId(actor, orgId); LocalDate today = ClockConfig.todayKst(clock); Instant decidedSince = clock.instant().minus(Duration.ofDays(14)); @@ -115,14 +117,16 @@ select count(*) filter (where r.decision = 'APPROVE') as approved, headroom.guidance()); List topWorkspaces = jdbcTemplate.query(""" - select v.workspace_id, g.name, count(*) as vm_count + select g.public_id as workspace_public_id, v.workspace_id, g.name, + count(*) as vm_count from vms v join workspaces g on g.id = v.workspace_id where (?::bigint is null or v.org_id = ?) and v.status <> 'DELETED' - group by v.workspace_id, g.name + group by g.public_id, v.workspace_id, g.name order by vm_count desc, v.workspace_id limit 10 - """, (rs, rowNum) -> new TopWorkspace(rs.getLong("workspace_id"), rs.getString("name"), + """, (rs, rowNum) -> new TopWorkspace( + rs.getObject("workspace_public_id", UUID.class), rs.getString("name"), rs.getLong("vm_count")), scopedOrgId, scopedOrgId); long published = count(""" @@ -200,7 +204,7 @@ and status in ('ACTIVE', 'RENEWING') List pools = new ArrayList<>(); ipPoolRepository.findAll(org.springframework.data.domain.Sort.by("id")).forEach(pool -> { IpamService.PoolUsage usage = ipamService.poolUsage(pool.getId()); - pools.add(new IpPoolUsage(pool.getId(), pool.getName(), pool.getCidr(), + pools.add(new IpPoolUsage(pool.getPublicId(), pool.getName(), pool.getCidr(), usage.allocatedCount(), usage.freeCount())); }); @@ -272,14 +276,14 @@ private List nodesLive(List nodes) { status = proxmoxClient.nodeStatus(node.getApiHost(), node.getName()); } catch (ProxmoxApiException | IllegalStateException e) { log.warn("Node {} live probe failed: {}", node.getName(), e.getMessage()); - live.add(NodeLiveResponse.unreachable(node.getId(), node.getName())); + live.add(NodeLiveResponse.unreachable(node.getPublicId(), node.getName())); continue; } // The client hands back whatever the PVE envelope held, so a 200 // with no data is possible and is not a reachable answer. if (status == null) { log.warn("Node {} live probe returned an empty status payload", node.getName()); - live.add(NodeLiveResponse.unreachable(node.getId(), node.getName())); + live.add(NodeLiveResponse.unreachable(node.getPublicId(), node.getName())); continue; } NodeStorageStatus storage = null; @@ -289,7 +293,7 @@ private List nodesLive(List nodes) { log.warn("Node {} guest-storage read failed, tile keeps the status half: {}", node.getName(), e.getMessage()); } - live.add(new NodeLiveResponse(node.getId(), node.getName(), true, + live.add(new NodeLiveResponse(node.getPublicId(), node.getName(), true, status.memory() == null ? null : status.memory().total(), status.memory() == null ? null : status.memory().used(), status.cpu(), @@ -358,13 +362,15 @@ private long count(String sql, Object... args) { * same panel over time and must be scoped by the identical rule; two copies * of a pinning rule is how one of them drifts open. */ - Long resolveOrgId(AuthenticatedUser actor, Long orgId) { + Long resolveOrgId(AuthenticatedUser actor, UUID orgId) { + Long requested = orgId == null ? null + : orgRepository.findByPublicId(orgId).map(Org::getId).orElse(null); if (actor.role().isOrgTier()) { if (actor.orgId() == null) { throw new ApiException(HttpStatus.FORBIDDEN, ErrorCodes.ACCESS_DENIED, "접근 권한이 없습니다", "관리 기관이 지정되지 않은 계정입니다."); } - if (orgId != null && !orgId.equals(actor.orgId())) { + if (orgId != null && !actor.orgId().equals(requested)) { throw orgNotFound(); } return actor.orgId(); @@ -372,10 +378,10 @@ Long resolveOrgId(AuthenticatedUser actor, Long orgId) { if (orgId == null) { return null; // SYS_ADMIN without drill-in → platform-wide } - if (orgRepository.findById(orgId).isEmpty()) { + if (requested == null) { throw orgNotFound(); } - return orgId; + return requested; } private static ApiException orgNotFound() { diff --git a/src/main/java/kr/ac/pusan/pickle/admin/AdminTaskController.java b/src/main/java/kr/ac/pusan/pickle/admin/AdminTaskController.java index 5958811e..1180ed72 100644 --- a/src/main/java/kr/ac/pusan/pickle/admin/AdminTaskController.java +++ b/src/main/java/kr/ac/pusan/pickle/admin/AdminTaskController.java @@ -6,6 +6,7 @@ import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import java.util.List; +import java.util.UUID; import kr.ac.pusan.pickle.admin.dto.AdminTaskResponse; import kr.ac.pusan.pickle.auth.dto.MessageResponse; import kr.ac.pusan.pickle.common.web.PageResponse; @@ -44,7 +45,7 @@ public PageResponse listAdminTasks( // a single value still binds to a one-element list (compat). @RequestParam(required = false) List status, @RequestParam(required = false) ProvisioningTaskKind kind, - @RequestParam(required = false) Long vmId, + @RequestParam(required = false) UUID vmId, @RequestParam(defaultValue = "0") @Min(0) int page, @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) { return adminTaskService.list(status, kind, vmId, page, size); @@ -54,7 +55,7 @@ public PageResponse listAdminTasks( @ResponseStatus(HttpStatus.ACCEPTED) public MessageResponse retryAdminTask( @AuthenticationPrincipal AuthenticatedUser principal, - @PathVariable long taskId, + @PathVariable UUID taskId, HttpServletRequest httpRequest) { return adminTaskService.retry(principal, taskId, clientIp(httpRequest)); } diff --git a/src/main/java/kr/ac/pusan/pickle/admin/AdminTaskService.java b/src/main/java/kr/ac/pusan/pickle/admin/AdminTaskService.java index 89135102..46630063 100644 --- a/src/main/java/kr/ac/pusan/pickle/admin/AdminTaskService.java +++ b/src/main/java/kr/ac/pusan/pickle/admin/AdminTaskService.java @@ -4,6 +4,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.UUID; import java.util.function.Function; import java.util.stream.Collectors; import kr.ac.pusan.pickle.admin.dto.AdminTaskResponse; @@ -77,8 +78,11 @@ public AdminTaskService(ProvisioningTaskRepository taskRepository, VmRepository * filter (v0.9.0 — OR over the given statuses; empty/null = no status filter). */ public PageResponse list(List statuses, - ProvisioningTaskKind kind, Long vmId, int page, int size) { + ProvisioningTaskKind kind, UUID publicVmId, int page, int size) { Specification spec = (root, query, cb) -> cb.conjunction(); + // An id no VM has filters to nothing, as a non-matching number did. + Long vmId = publicVmId == null ? null + : vmRepository.findByPublicId(publicVmId).map(Vm::getId).orElse(-1L); if (statuses != null && !statuses.isEmpty()) { spec = spec.and((root, query, cb) -> root.get("status").in(statuses)); } @@ -95,9 +99,9 @@ public PageResponse list(List statuse Map vms = vmRepository.findAllById(result.getContent().stream() .map(ProvisioningTask::getVmId).distinct().toList()).stream() .collect(Collectors.toMap(Vm::getId, Function.identity())); - Map orgNames = orgRepository.findAllById(vms.values().stream() + Map orgs = orgRepository.findAllById(vms.values().stream() .map(Vm::getOrgId).filter(Objects::nonNull).distinct().toList()).stream() - .collect(Collectors.toMap(Org::getId, Org::getName)); + .collect(Collectors.toMap(Org::getId, Function.identity())); // History-preserving workspace-name join (v0.9.0): reads all workspaces since a // task's VM may reference a since-deleted workspace. Map workspaceNames = workspaceRepository.findAllById(vms.values().stream() @@ -107,11 +111,13 @@ public PageResponse list(List statuse List content = result.getContent().stream() .map(task -> { Vm vm = vms.get(task.getVmId()); - String orgName = vm == null || vm.getOrgId() == null ? null - : orgNames.get(vm.getOrgId()); + Org org = vm == null || vm.getOrgId() == null ? null : orgs.get(vm.getOrgId()); String workspaceName = vm == null || vm.getWorkspaceId() == null ? null : workspaceNames.get(vm.getWorkspaceId()); - return AdminTaskResponse.from(task, vm, orgName, workspaceName); + return AdminTaskResponse.from(task, vm, + vm == null ? null : vm.getPublicId(), + org == null ? null : org.getPublicId(), + org == null ? null : org.getName(), workspaceName); }) .toList(); return PageResponse.of(content, result); @@ -119,12 +125,12 @@ public PageResponse list(List statuse /** NEEDS_ADMIN → RETRYING CAS + after-commit re-enqueue of the kind's job. */ @Transactional - public MessageResponse retry(AuthenticatedUser actor, long taskId, String ip) { - ProvisioningTask task = taskRepository.findById(taskId) + public MessageResponse retry(AuthenticatedUser actor, UUID publicTaskId, String ip) { + ProvisioningTask task = taskRepository.findByPublicId(publicTaskId) .orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, ErrorCodes.RESOURCE_NOT_FOUND, "리소스를 찾을 수 없습니다", "작업을 찾을 수 없습니다.")); - if (taskRepository.requeueForAdminRetry(taskId, Instant.now()) == 0) { + if (taskRepository.requeueForAdminRetry(task.getId(), Instant.now()) == 0) { throw notRetryable(); } long vmId = task.getVmId(); @@ -142,7 +148,7 @@ public void afterCommit() { } }); auditService.recordAfterCommit(actor.id(), actor.role().name(), AuditService.TASK_RETRY, - "provisioning_task", taskId, + "provisioning_task", task.getPublicId(), Map.of("vmId", vmId, "kind", task.getKind().name()), ip); return new MessageResponse("작업 재시도를 접수했습니다. 잠시 후 작업 상태가 갱신됩니다."); } diff --git a/src/main/java/kr/ac/pusan/pickle/admin/AdminUserController.java b/src/main/java/kr/ac/pusan/pickle/admin/AdminUserController.java index cada4a9f..f58404e6 100644 --- a/src/main/java/kr/ac/pusan/pickle/admin/AdminUserController.java +++ b/src/main/java/kr/ac/pusan/pickle/admin/AdminUserController.java @@ -7,6 +7,7 @@ import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.Size; +import java.util.UUID; import kr.ac.pusan.pickle.admin.dto.DisableUserRequest; import kr.ac.pusan.pickle.admin.dto.UserAdminDetailResponse; import kr.ac.pusan.pickle.admin.dto.UserAdminViewResponse; @@ -50,7 +51,7 @@ public PageResponse listUsers( @RequestParam(required = false) @Size(min = 1, max = 100) String q, @RequestParam(required = false) UserStatus status, @RequestParam(required = false) UserRole role, - @RequestParam(required = false) Long orgId, + @RequestParam(required = false) UUID orgId, @io.swagger.v3.oas.annotations.Parameter(schema = @io.swagger.v3.oas.annotations.media.Schema( allowableValues = {"name", "-name", "email", "-email", "createdAt", "-createdAt"})) @RequestParam(required = false) String sort, @@ -61,14 +62,14 @@ public PageResponse listUsers( @GetMapping("/{userId}") public UserAdminDetailResponse getUser(@AuthenticationPrincipal AuthenticatedUser principal, - @PathVariable long userId) { + @PathVariable UUID userId) { return adminUserQueryService.getUser(principal, userId); } @PostMapping("/{userId}/disable") @PreAuthorize("hasRole('SYS_ADMIN')") public UserAdminDetailResponse disableUser(@AuthenticationPrincipal AuthenticatedUser principal, - @PathVariable long userId, @Valid @RequestBody DisableUserRequest request, + @PathVariable UUID userId, @Valid @RequestBody DisableUserRequest request, HttpServletRequest httpRequest) { return adminUserService.disable(principal, userId, request.reason(), clientIp(httpRequest)); } @@ -76,14 +77,14 @@ public UserAdminDetailResponse disableUser(@AuthenticationPrincipal Authenticate @PostMapping("/{userId}/enable") @PreAuthorize("hasRole('SYS_ADMIN')") public UserAdminDetailResponse enableUser(@AuthenticationPrincipal AuthenticatedUser principal, - @PathVariable long userId, HttpServletRequest httpRequest) { + @PathVariable UUID userId, HttpServletRequest httpRequest) { return adminUserService.enable(principal, userId, clientIp(httpRequest)); } @PostMapping("/{userId}/mfa-reset") @PreAuthorize("hasRole('SYS_ADMIN')") public MessageResponse resetUserMfa(@AuthenticationPrincipal AuthenticatedUser principal, - @PathVariable long userId, HttpServletRequest httpRequest) { + @PathVariable UUID userId, HttpServletRequest httpRequest) { return adminUserService.resetMfa(principal, userId, clientIp(httpRequest)); } } diff --git a/src/main/java/kr/ac/pusan/pickle/admin/AdminUserQueryService.java b/src/main/java/kr/ac/pusan/pickle/admin/AdminUserQueryService.java index 0c719e99..abbaa552 100644 --- a/src/main/java/kr/ac/pusan/pickle/admin/AdminUserQueryService.java +++ b/src/main/java/kr/ac/pusan/pickle/admin/AdminUserQueryService.java @@ -5,6 +5,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.UUID; import java.util.stream.Collectors; import kr.ac.pusan.pickle.admin.dto.UserAdminDetailResponse; import kr.ac.pusan.pickle.admin.dto.UserAdminViewResponse; @@ -42,6 +43,12 @@ */ @Service public class AdminUserQueryService { + /** + * The scope an id no org has resolves to: a filter value no row carries, so + * the page comes back empty exactly as a non-matching number made it. + */ + private static final Long NO_SUCH_ORG = -1L; + /** Whitelisted {@code sort} → SQL order-by. Default is latest signup ({@code -id}). */ private static final Map SORTS = Map.of( @@ -73,7 +80,7 @@ public AdminUserQueryService(JdbcTemplate jdbcTemplate, UserRepository userRepos @Transactional(readOnly = true) public PageResponse listUsers(AuthenticatedUser actor, String q, - UserStatus status, UserRole role, Long orgId, String sort, int page, int size) { + UserStatus status, UserRole role, UUID orgId, String sort, int page, int size) { Long scopedOrgId = scopeOrgId(actor, orgId); StringBuilder where = new StringBuilder(" where 1 = 1"); List params = new ArrayList<>(); @@ -110,27 +117,32 @@ public PageResponse listUsers(AuthenticatedUser actor, St List pageParams = new ArrayList<>(params); pageParams.add(size); pageParams.add((long) page * size); - List rows = jdbcTemplate.query(""" - select u.id, u.email, u.name, u.role, u.org_id, u.status, u.created_at - """ + base + where + " order by " + resolveOrder(sort) + " limit ? offset ?", - (rs, rowNum) -> mapView(rs), pageParams.toArray()); + // u.public_id and o.public_id are selected beside the internal keys: the + // response carries the public pair, and u.id is still what the 2FA + // set-membership query below joins on. + List rows = jdbcTemplate.query(""" + select u.id, u.public_id, u.email, u.name, u.role, o.public_id as org_public_id, + u.status, u.created_at + """ + base + " left join orgs o on o.id = u.org_id" + where + + " order by " + resolveOrder(sort) + " limit ? offset ?", + (rs, rowNum) -> mapRow(rs), pageParams.toArray()); // Real 2FA state: one set-membership query over the page's user ids // (the console mfa-reset button keys off this). Set enrolled = rows.isEmpty() ? Set.of() : Set.copyOf(userMfaRepository.findEnrolledUserIds( - rows.stream().map(UserAdminViewResponse::id).toList())); + rows.stream().map(Row::id).toList())); List content = rows.stream() - .map(v -> new UserAdminViewResponse(v.id(), v.email(), v.name(), v.role(), v.orgId(), - v.status(), enrolled.contains(v.id()), v.createdAt())) + .map(v -> new UserAdminViewResponse(v.publicId(), v.email(), v.name(), v.role(), + v.orgId(), v.status(), enrolled.contains(v.id()), v.createdAt())) .toList(); int totalPages = size > 0 ? (int) Math.ceil((double) totalElements / size) : 0; return new PageResponse<>(content, page, size, totalElements, totalPages); } @Transactional(readOnly = true) - public UserAdminDetailResponse getUser(AuthenticatedUser actor, long userId) { - Long scopedOrgId = scopeOrgId(actor, orgIdIfOrgAdmin(actor)); - User user = userRepository.findById(userId).filter(u -> inScope(u.getId(), scopedOrgId)) + public UserAdminDetailResponse getUser(AuthenticatedUser actor, UUID userId) { + Long scopedOrgId = orgIdIfOrgAdmin(actor); + User user = userRepository.findByPublicId(userId).filter(u -> inScope(u.getId(), scopedOrgId)) .orElseThrow(AdminUserQueryService::userNotFound); List liveMemberships = workspaceMemberRepository.findWithWorkspaceByUserId(user.getId()).stream() @@ -146,8 +158,9 @@ public UserAdminDetailResponse getUser(AuthenticatedUser actor, long userId) { List statusChanges = mapStatusChanges(userStatusChangeRepository.findByUserIdOrderByChangedAtDescIdDesc(user.getId())); - return new UserAdminDetailResponse(user.getId(), user.getEmail(), user.getName(), user.getRole(), - user.getOrgId(), user.getStatus(), userMfaRepository.isEnrolled(user.getId()), + return new UserAdminDetailResponse(user.getPublicId(), user.getEmail(), user.getName(), + user.getRole(), orgPublicId(user.getOrgId()), user.getStatus(), + userMfaRepository.isEnrolled(user.getId()), user.getCreatedAt(), user.getWithdrawnAt(), user.getDisabledAt(), user.getDisabledReason(), memberships, activeVmCount, statusChanges); @@ -157,12 +170,15 @@ public UserAdminDetailResponse getUser(AuthenticatedUser actor, long userId) { private List mapStatusChanges(List changes) { List actorIds = changes.stream().map(UserStatusChange::getActorId) .filter(id -> id != null).distinct().toList(); - Map emails = userRepository.findAllById(actorIds).stream() + List actors = userRepository.findAllById(actorIds); + Map emails = actors.stream() .collect(Collectors.toMap(User::getId, User::getEmail)); + Map publicIds = actors.stream() + .collect(Collectors.toMap(User::getId, User::getPublicId)); return changes.stream() .map(change -> new UserStatusChangeResponse(change.getFromStatus(), change.getToStatus(), - change.getActorId(), emails.get(change.getActorId()), change.getReason(), - change.getChangedAt())) + publicIds.get(change.getActorId()), emails.get(change.getActorId()), + change.getReason(), change.getChangedAt())) .toList(); } @@ -178,13 +194,25 @@ private boolean inScope(long userId, Long scopedOrgId) { return Boolean.TRUE.equals(visible); } - private static UserAdminViewResponse mapView(java.sql.ResultSet rs) throws java.sql.SQLException { - return new UserAdminViewResponse(rs.getLong("id"), rs.getString("email"), rs.getString("name"), - UserRole.valueOf(rs.getString("role")), rs.getObject("org_id", Long.class), - UserStatus.valueOf(rs.getString("status")), false, + /** One list row: the internal key the 2FA join needs, plus the public view. */ + private record Row(long id, UUID publicId, String email, String name, UserRole role, + UUID orgId, UserStatus status, java.time.Instant createdAt) { + } + + private static Row mapRow(java.sql.ResultSet rs) throws java.sql.SQLException { + return new Row(rs.getLong("id"), rs.getObject("public_id", UUID.class), + rs.getString("email"), rs.getString("name"), + UserRole.valueOf(rs.getString("role")), rs.getObject("org_public_id", UUID.class), + UserStatus.valueOf(rs.getString("status")), rs.getObject("created_at", OffsetDateTime.class).toInstant()); } + private UUID orgPublicId(Long orgId) { + return orgId == null ? null : jdbcTemplate.query( + "select public_id from orgs where id = ?", + rs -> rs.next() ? rs.getObject(1, UUID.class) : null, orgId); + } + private static String resolveOrder(String sort) { if (sort == null || sort.isBlank()) { return "u.id desc"; @@ -211,15 +239,22 @@ private static ApiException userNotFound() { } /** Org tier pinned to their org; another org's id answers 404 (mask). */ - private static Long scopeOrgId(AuthenticatedUser actor, Long orgId) { + private Long scopeOrgId(AuthenticatedUser actor, UUID orgId) { + Long requested = orgId == null ? null : jdbcTemplate.query( + "select id from orgs where public_id = ?", + rs -> rs.next() ? rs.getLong(1) : null, orgId); if (!actor.role().isOrgTier()) { - return orgId; + // An id no org has filters to nothing, as a non-matching number did. + if (orgId != null && requested == null) { + return NO_SUCH_ORG; + } + return requested; } if (actor.orgId() == null) { throw new ApiException(HttpStatus.FORBIDDEN, ErrorCodes.ACCESS_DENIED, "접근 권한이 없습니다", "관리 기관이 지정되지 않은 계정입니다."); } - if (orgId != null && !orgId.equals(actor.orgId())) { + if (orgId != null && !actor.orgId().equals(requested)) { throw new ApiException(HttpStatus.NOT_FOUND, ErrorCodes.RESOURCE_NOT_FOUND, "리소스를 찾을 수 없습니다", "해당 기관을 찾을 수 없습니다."); } diff --git a/src/main/java/kr/ac/pusan/pickle/admin/AdminUserService.java b/src/main/java/kr/ac/pusan/pickle/admin/AdminUserService.java index a904be3d..d745a2bc 100644 --- a/src/main/java/kr/ac/pusan/pickle/admin/AdminUserService.java +++ b/src/main/java/kr/ac/pusan/pickle/admin/AdminUserService.java @@ -3,6 +3,7 @@ import java.time.Instant; import java.util.LinkedHashMap; import java.util.Map; +import java.util.UUID; import kr.ac.pusan.pickle.admin.dto.UserAdminDetailResponse; import kr.ac.pusan.pickle.audit.AuditService; import kr.ac.pusan.pickle.auth.RefreshTokenService; @@ -58,17 +59,17 @@ public AdminUserService(UserRepository userRepository, * 404 if the user is unknown, 409 if they are not enrolled. */ @Transactional - public MessageResponse resetMfa(AuthenticatedUser actor, long userId, String ip) { - User target = userRepository.findById(userId).orElseThrow(AdminUserService::userNotFound); + public MessageResponse resetMfa(AuthenticatedUser actor, UUID userId, String ip) { + User target = userRepository.findByPublicId(userId).orElseThrow(AdminUserService::userNotFound); mfaService.adminReset(actor.id(), actor.role().name(), target, ip); return new MessageResponse( "2단계 인증을 초기화했습니다. 사용자는 비밀번호로 로그인한 뒤 다시 등록해야 합니다."); } @Transactional - public UserAdminDetailResponse disable(AuthenticatedUser actor, long userId, String reason, String ip) { - User user = userRepository.findById(userId).orElseThrow(AdminUserService::userNotFound); - if (actor.id().equals(userId)) { + public UserAdminDetailResponse disable(AuthenticatedUser actor, UUID userId, String reason, String ip) { + User user = userRepository.findByPublicId(userId).orElseThrow(AdminUserService::userNotFound); + if (actor.id().equals(user.getId())) { throw new ApiException(HttpStatus.CONFLICT, ErrorCodes.ACCOUNT_SELF_DISABLE_FORBIDDEN, "비활성화할 수 없는 계정입니다", "본인 계정은 비활성화할 수 없습니다."); } @@ -92,7 +93,7 @@ public UserAdminDetailResponse disable(AuthenticatedUser actor, long userId, Str UserStatus.DISABLED, actor.id(), reason)); auditService.recordAfterCommit(actor.id(), actor.role().name(), AuditService.USER_DISABLE, - "user", user.getId(), Map.of("reason", reason, "fromStatus", fromStatus.name()), ip); + "user", user.getPublicId(), Map.of("reason", reason, "fromStatus", fromStatus.name()), ip); Map args = new LinkedHashMap<>(); args.put("userId", user.getId()); args.put("userEmail", user.getEmail()); @@ -109,8 +110,8 @@ public UserAdminDetailResponse disable(AuthenticatedUser actor, long userId, Str } @Transactional - public UserAdminDetailResponse enable(AuthenticatedUser actor, long userId, String ip) { - User user = userRepository.findById(userId).orElseThrow(AdminUserService::userNotFound); + public UserAdminDetailResponse enable(AuthenticatedUser actor, UUID userId, String ip) { + User user = userRepository.findByPublicId(userId).orElseThrow(AdminUserService::userNotFound); if (user.getStatus() != UserStatus.DISABLED) { throw new ApiException(HttpStatus.CONFLICT, ErrorCodes.ACCOUNT_NOT_DISABLED, "활성화할 수 없는 계정입니다", @@ -129,7 +130,7 @@ public UserAdminDetailResponse enable(AuthenticatedUser actor, long userId, Stri restored, actor.id(), null)); auditService.recordAfterCommit(actor.id(), actor.role().name(), AuditService.USER_ENABLE, - "user", user.getId(), Map.of("toStatus", restored.name()), ip); + "user", user.getPublicId(), Map.of("toStatus", restored.name()), ip); notificationService.publish(user.getId(), NotificationEvent.ACCOUNT_ENABLED, Map.of("userId", user.getId(), "userEmail", user.getEmail()), "account_enabled:" + user.getId() + ":" + Instant.now().toEpochMilli()); diff --git a/src/main/java/kr/ac/pusan/pickle/admin/AdminVmController.java b/src/main/java/kr/ac/pusan/pickle/admin/AdminVmController.java index ab53fc65..c6310301 100644 --- a/src/main/java/kr/ac/pusan/pickle/admin/AdminVmController.java +++ b/src/main/java/kr/ac/pusan/pickle/admin/AdminVmController.java @@ -7,6 +7,7 @@ import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.Size; +import java.util.UUID; import kr.ac.pusan.pickle.admin.dto.ForceDeleteVmRequest; import kr.ac.pusan.pickle.admin.dto.ScheduleVmDeletionRequest; import kr.ac.pusan.pickle.admin.dto.VmGatewayBlockUpdateRequest; @@ -67,8 +68,8 @@ public AdminVmController(AdminVmQueryService adminVmQueryService, @PreAuthorize("hasAnyRole('ORG_ADMIN', 'ORG_MANAGER', 'SYS_ADMIN', 'SYS_MANAGER')") public PageResponse listAdminVms( @AuthenticationPrincipal AuthenticatedUser principal, - @RequestParam(required = false) Long orgId, - @RequestParam(required = false) Long workspaceId, + @RequestParam(required = false) UUID orgId, + @RequestParam(required = false) UUID workspaceId, @RequestParam(required = false) VmStatus status, @RequestParam(required = false) @Min(1) Integer expiringInDays, @RequestParam(required = false) Boolean expired, @@ -87,7 +88,7 @@ public PageResponse listAdminVms( @PreAuthorize("hasAnyRole('ORG_ADMIN', 'ORG_MANAGER', 'SYS_ADMIN', 'SYS_MANAGER')") public VmDetailResponse getAdminVm( @AuthenticationPrincipal AuthenticatedUser principal, - @PathVariable long vmId) { + @PathVariable UUID vmId) { return adminVmQueryService.get(principal, vmId); } @@ -95,7 +96,7 @@ public VmDetailResponse getAdminVm( @PreAuthorize("hasAnyRole('ORG_ADMIN', 'ORG_MANAGER', 'SYS_ADMIN', 'SYS_MANAGER')") public PageResponse listAdminVmEvents( @AuthenticationPrincipal AuthenticatedUser principal, - @PathVariable long vmId, + @PathVariable UUID vmId, @RequestParam(defaultValue = "0") @Min(0) int page, @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) { return adminVmQueryService.events(principal, vmId, page, size); @@ -106,7 +107,7 @@ public PageResponse listAdminVmEvents( @ResponseStatus(HttpStatus.ACCEPTED) public MessageResponse adminStartVm( @AuthenticationPrincipal AuthenticatedUser principal, - @PathVariable long vmId, HttpServletRequest httpRequest) { + @PathVariable UUID vmId, HttpServletRequest httpRequest) { return vmLifecycleService.adminStart(principal, vmId, clientIp(httpRequest)); } @@ -115,7 +116,7 @@ public MessageResponse adminStartVm( @ResponseStatus(HttpStatus.ACCEPTED) public MessageResponse adminShutdownVm( @AuthenticationPrincipal AuthenticatedUser principal, - @PathVariable long vmId, HttpServletRequest httpRequest) { + @PathVariable UUID vmId, HttpServletRequest httpRequest) { return vmLifecycleService.adminShutdown(principal, vmId, clientIp(httpRequest)); } @@ -124,7 +125,7 @@ public MessageResponse adminShutdownVm( @ResponseStatus(HttpStatus.ACCEPTED) public MessageResponse adminRebootVm( @AuthenticationPrincipal AuthenticatedUser principal, - @PathVariable long vmId, HttpServletRequest httpRequest) { + @PathVariable UUID vmId, HttpServletRequest httpRequest) { return vmLifecycleService.adminReboot(principal, vmId, clientIp(httpRequest)); } @@ -133,7 +134,7 @@ public MessageResponse adminRebootVm( @ResponseStatus(HttpStatus.ACCEPTED) public MessageResponse adminForceStopVm( @AuthenticationPrincipal AuthenticatedUser principal, - @PathVariable long vmId, HttpServletRequest httpRequest) { + @PathVariable UUID vmId, HttpServletRequest httpRequest) { return vmLifecycleService.adminForceStop(principal, vmId, clientIp(httpRequest)); } @@ -142,7 +143,7 @@ public MessageResponse adminForceStopVm( @PreAuthorize("hasAnyRole('ORG_ADMIN', 'ORG_MANAGER', 'SYS_ADMIN', 'SYS_MANAGER')") public VmDetailResponse updateVmPeriod( @AuthenticationPrincipal AuthenticatedUser principal, - @PathVariable long vmId, + @PathVariable UUID vmId, @Valid @RequestBody VmPeriodUpdateRequest request, HttpServletRequest httpRequest) { return vmPeriodService.updatePeriod(principal, vmId, request, clientIp(httpRequest)); @@ -156,7 +157,7 @@ public VmDetailResponse updateVmPeriod( @PreAuthorize("hasRole('SYS_ADMIN')") public VmDetailResponse updateVmGatewayBlock( @AuthenticationPrincipal AuthenticatedUser principal, - @PathVariable long vmId, + @PathVariable UUID vmId, @Valid @RequestBody VmGatewayBlockUpdateRequest request, HttpServletRequest httpRequest) { return vmGatewayBlockService.updateBlock(principal, vmId, request, clientIp(httpRequest)); @@ -166,7 +167,7 @@ public VmDetailResponse updateVmGatewayBlock( @ResponseStatus(HttpStatus.ACCEPTED) public VmDeletionResponse scheduleVmDeletion( @AuthenticationPrincipal AuthenticatedUser principal, - @PathVariable long vmId, + @PathVariable UUID vmId, @Valid @RequestBody ScheduleVmDeletionRequest request, HttpServletRequest httpRequest) { return vmDeletionService.scheduleDeletion(principal, vmId, request, @@ -175,7 +176,7 @@ public VmDeletionResponse scheduleVmDeletion( @PostMapping("/{vmId}/cancel-scheduled-delete") public MessageResponse cancelScheduledVmDeletion( - @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable long vmId, + @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable UUID vmId, HttpServletRequest httpRequest) { return vmDeletionService.cancelScheduledDeletion(principal, vmId, clientIp(httpRequest)); } @@ -185,7 +186,7 @@ public MessageResponse cancelScheduledVmDeletion( @ResponseStatus(HttpStatus.ACCEPTED) public MessageResponse forceDeleteVm( @AuthenticationPrincipal AuthenticatedUser principal, - @PathVariable long vmId, + @PathVariable UUID vmId, @Valid @RequestBody ForceDeleteVmRequest request, HttpServletRequest httpRequest) { return vmDeletionService.forceDelete(principal, vmId, request, diff --git a/src/main/java/kr/ac/pusan/pickle/admin/AdminVmFlavorController.java b/src/main/java/kr/ac/pusan/pickle/admin/AdminVmFlavorController.java index 2eaedde9..11a977f7 100644 --- a/src/main/java/kr/ac/pusan/pickle/admin/AdminVmFlavorController.java +++ b/src/main/java/kr/ac/pusan/pickle/admin/AdminVmFlavorController.java @@ -5,6 +5,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import java.util.List; +import java.util.UUID; import kr.ac.pusan.pickle.admin.dto.CreateVmFlavorRequest; import kr.ac.pusan.pickle.admin.dto.UpdateVmFlavorRequest; import kr.ac.pusan.pickle.inventory.dto.VmFlavorResponse; @@ -57,7 +58,7 @@ public VmFlavorResponse createAdminVmFlavor( @PreAuthorize("hasRole('SYS_ADMIN')") public VmFlavorResponse updateAdminVmFlavor( @AuthenticationPrincipal AuthenticatedUser principal, - @PathVariable long flavorId, + @PathVariable UUID flavorId, @Valid @RequestBody UpdateVmFlavorRequest request, HttpServletRequest httpRequest) { return adminInventoryService.updateFlavor(principal, flavorId, request, clientIp(httpRequest)); diff --git a/src/main/java/kr/ac/pusan/pickle/admin/AdminVmQueryService.java b/src/main/java/kr/ac/pusan/pickle/admin/AdminVmQueryService.java index e2ad3a8f..5994c171 100644 --- a/src/main/java/kr/ac/pusan/pickle/admin/AdminVmQueryService.java +++ b/src/main/java/kr/ac/pusan/pickle/admin/AdminVmQueryService.java @@ -4,6 +4,7 @@ import java.time.LocalDate; import java.util.List; import java.util.Map; +import java.util.UUID; import java.util.stream.Collectors; import kr.ac.pusan.pickle.common.error.ApiException; import kr.ac.pusan.pickle.common.error.ErrorCodes; @@ -85,26 +86,33 @@ public AdminVmQueryService(VmRepository vmRepository, WorkspaceRepository worksp * {@code myResourceRole} is null and password reveal stays off. */ @Transactional(readOnly = true) - public VmDetailResponse get(AuthenticatedUser actor, long vmId) { + public VmDetailResponse get(AuthenticatedUser actor, UUID vmId) { Vm vm = adminVmAccess.requireOrgScopedVm(actor, vmId); return vmQueryService.detailOf(vm, null); } /** Contract {@code GET /admin/vms/{vmId}/events} (v0.17.0): org-scoped history. */ @Transactional(readOnly = true) - public PageResponse events(AuthenticatedUser actor, long vmId, int page, + public PageResponse events(AuthenticatedUser actor, UUID vmId, int page, int size) { - adminVmAccess.requireOrgScopedVm(actor, vmId); - return vmQueryService.eventsOf(vmId, page, size); + return vmQueryService.eventsOf(adminVmAccess.requireOrgScopedVm(actor, vmId).getId(), + page, size); } @Transactional(readOnly = true) - public PageResponse list(AuthenticatedUser actor, Long orgId, Long workspaceId, + public PageResponse list(AuthenticatedUser actor, UUID orgId, UUID workspaceId, VmStatus status, Integer expiringInDays, Boolean expired, String q, String sort, int page, int size) { Long scopedOrgId = scopeOrgId(actor, orgId); Pageable pageable = PageRequest.of(page, size, resolveSort(sort).and(Sort.by(Sort.Direction.DESC, "id"))); + // An id no org or workspace has filters to nothing, as a non-matching + // numeric id did — it is a filter, not an addressed resource. + Long scopedWorkspaceId = workspaceId == null ? null + : workspaceRepository.findByPublicId(workspaceId).map(Workspace::getId).orElse(null); + if ((orgId != null && scopedOrgId == null) || (workspaceId != null && scopedWorkspaceId == null)) { + return PageResponse.of(List.of(), Page.empty(pageable)); + } Specification spec = (root, query, cb) -> cb.conjunction(); if (q != null && !q.isBlank()) { String pattern = "%" + escapeLike(q.trim().toLowerCase()) + "%"; @@ -115,8 +123,8 @@ public PageResponse list(AuthenticatedUser actor, Long orgId, if (scopedOrgId != null) { spec = spec.and((root, query, cb) -> cb.equal(root.get("orgId"), scopedOrgId)); } - if (workspaceId != null) { - spec = spec.and((root, query, cb) -> cb.equal(root.get("workspaceId"), workspaceId)); + if (scopedWorkspaceId != null) { + spec = spec.and((root, query, cb) -> cb.equal(root.get("workspaceId"), scopedWorkspaceId)); } if (status != null) { spec = spec.and((root, query, cb) -> cb.equal(root.get("status"), status)); @@ -138,18 +146,25 @@ public PageResponse list(AuthenticatedUser actor, Long orgId, } Page result = vmRepository.findAll(spec, pageable); List vms = result.getContent(); - Map workspaceNames = workspaceRepository.findAllById( + Map workspaces = workspaceRepository.findAllById( vms.stream().map(Vm::getWorkspaceId).distinct().toList()) - .stream().collect(Collectors.toMap(Workspace::getId, Workspace::getName)); + .stream().collect(Collectors.toMap(Workspace::getId, java.util.function.Function.identity())); Map orgNames = orgRepository.findAllById( vms.stream().map(Vm::getOrgId).filter(java.util.Objects::nonNull) .distinct().toList()) .stream().collect(Collectors.toMap(Org::getId, Org::getName)); Map displayNames = vmSettingsService.displayNames( vms.stream().map(Vm::getId).toList()); + Map requestIds = vmQueryService.requestPublicIds(vms); return PageResponse.of(vms.stream() - .map(vm -> VmSummaryResponse.from(vm, workspaceNames.getOrDefault(vm.getWorkspaceId(), ""), - orgNames.get(vm.getOrgId()), displayNames.get(vm.getId()))) + .map(vm -> { + Workspace workspace = workspaces.get(vm.getWorkspaceId()); + return VmSummaryResponse.from(vm, + workspace == null ? null : workspace.getPublicId(), + workspace == null ? "" : workspace.getName(), + orgNames.get(vm.getOrgId()), displayNames.get(vm.getId()), + requestIds.get(vm.getRequestId())); + }) .toList(), result); } @@ -172,16 +187,18 @@ private static String escapeLike(String value) { return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_"); } - private static Long scopeOrgId(AuthenticatedUser actor, Long orgId) { + private Long scopeOrgId(AuthenticatedUser actor, UUID orgId) { + Long requested = orgId == null ? null + : orgRepository.findByPublicId(orgId).map(Org::getId).orElse(null); if (!actor.role().isOrgTier()) { - return orgId; + return requested; } if (actor.orgId() == null) { // Defensive: an org-tier actor without a managed org sees nothing. throw new ApiException(HttpStatus.FORBIDDEN, ErrorCodes.ACCESS_DENIED, "접근 권한이 없습니다", "관리 기관이 지정되지 않은 계정입니다."); } - if (orgId != null && !orgId.equals(actor.orgId())) { + if (orgId != null && !actor.orgId().equals(requested)) { throw new ApiException(HttpStatus.NOT_FOUND, ErrorCodes.RESOURCE_NOT_FOUND, "리소스를 찾을 수 없습니다", "해당 기관을 찾을 수 없습니다."); } diff --git a/src/main/java/kr/ac/pusan/pickle/admin/AdminWorkspaceController.java b/src/main/java/kr/ac/pusan/pickle/admin/AdminWorkspaceController.java index f33e19fa..7213421b 100644 --- a/src/main/java/kr/ac/pusan/pickle/admin/AdminWorkspaceController.java +++ b/src/main/java/kr/ac/pusan/pickle/admin/AdminWorkspaceController.java @@ -1,6 +1,7 @@ package kr.ac.pusan.pickle.admin; import java.util.List; +import java.util.UUID; import kr.ac.pusan.pickle.admin.dto.AdminWorkspaceDetailResponse; import kr.ac.pusan.pickle.admin.dto.AdminWorkspaceOptionResponse; import kr.ac.pusan.pickle.security.AuthenticatedUser; @@ -32,14 +33,14 @@ public AdminWorkspaceController(AdminWorkspaceQueryService adminWorkspaceQuerySe @GetMapping public List listAdminWorkspaces( @AuthenticationPrincipal AuthenticatedUser principal, - @RequestParam(required = false) Long orgId) { + @RequestParam(required = false) UUID orgId) { return adminWorkspaceQueryService.list(principal, orgId); } @GetMapping("/{workspaceId}") public AdminWorkspaceDetailResponse getAdminWorkspace( @AuthenticationPrincipal AuthenticatedUser principal, - @PathVariable long workspaceId) { + @PathVariable UUID workspaceId) { return adminWorkspaceQueryService.get(principal, workspaceId); } } diff --git a/src/main/java/kr/ac/pusan/pickle/admin/AdminWorkspaceQueryService.java b/src/main/java/kr/ac/pusan/pickle/admin/AdminWorkspaceQueryService.java index 5e66a9f7..ceb52edd 100644 --- a/src/main/java/kr/ac/pusan/pickle/admin/AdminWorkspaceQueryService.java +++ b/src/main/java/kr/ac/pusan/pickle/admin/AdminWorkspaceQueryService.java @@ -1,6 +1,7 @@ package kr.ac.pusan.pickle.admin; import java.util.List; +import java.util.UUID; import kr.ac.pusan.pickle.admin.dto.AdminWorkspaceDetailResponse; import kr.ac.pusan.pickle.admin.dto.AdminWorkspaceMemberResponse; import kr.ac.pusan.pickle.admin.dto.AdminWorkspaceOptionResponse; @@ -28,6 +29,12 @@ */ @Service public class AdminWorkspaceQueryService { + /** + * The scope an id no org has resolves to: a filter value no row carries, so + * the page comes back empty exactly as a non-matching number made it. + */ + private static final Long NO_SUCH_ORG = -1L; + private final JdbcTemplate jdbcTemplate; @@ -36,10 +43,10 @@ public AdminWorkspaceQueryService(JdbcTemplate jdbcTemplate) { } @Transactional(readOnly = true) - public List list(AuthenticatedUser actor, Long orgId) { + public List list(AuthenticatedUser actor, UUID orgId) { Long scopedOrgId = scopeOrgId(actor, orgId); String select = """ - select g.id, g.name, g.kind, g.created_at, + select g.public_id, g.name, g.kind, g.created_at, (select count(*) from workspace_members gm join users mu on mu.id = gm.user_id where gm.workspace_id = g.id and mu.status = 'ACTIVE') @@ -59,7 +66,8 @@ public List list(AuthenticatedUser actor, Long org private static AdminWorkspaceOptionResponse toOption(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException { - return new AdminWorkspaceOptionResponse(rs.getLong("id"), rs.getString("name"), + return new AdminWorkspaceOptionResponse(rs.getObject("public_id", UUID.class), + rs.getString("name"), rs.getLong("member_count"), WorkspaceKind.valueOf(rs.getString("kind")), rs.getTimestamp("created_at").toInstant()); @@ -72,7 +80,12 @@ private static AdminWorkspaceOptionResponse toOption(java.sql.ResultSet rs, int * user-facing workspace detail's member-only 403. */ @Transactional(readOnly = true) - public AdminWorkspaceDetailResponse get(AuthenticatedUser actor, long workspaceId) { + public AdminWorkspaceDetailResponse get(AuthenticatedUser actor, UUID publicWorkspaceId) { + Long workspaceId = jdbcTemplate.query("select id from workspaces where public_id = ?", + rs -> rs.next() ? rs.getLong(1) : null, publicWorkspaceId); + if (workspaceId == null) { + throw workspaceNotFound(); + } Long scopedOrgId = scopeOrgId(actor, null); if (scopedOrgId != null) { Boolean linked = jdbcTemplate.queryForObject( @@ -83,7 +96,7 @@ public AdminWorkspaceDetailResponse get(AuthenticatedUser actor, long workspaceI } } List rows = jdbcTemplate.query(""" - select g.id, g.kind, g.name, g.description, g.created_at, + select g.public_id, g.kind, g.name, g.description, g.created_at, (select count(*) from workspace_members gm join users mu on mu.id = gm.user_id where gm.workspace_id = g.id and mu.status = 'ACTIVE') @@ -92,7 +105,8 @@ public AdminWorkspaceDetailResponse get(AuthenticatedUser actor, long workspaceI where v.workspace_id = g.id and v.status <> 'DELETED') as vm_count from workspaces g where g.id = ? and g.deleted_at is null - """, (rs, rowNum) -> new AdminWorkspaceDetailResponse(rs.getLong("id"), + """, (rs, rowNum) -> new AdminWorkspaceDetailResponse( + rs.getObject("public_id", UUID.class), WorkspaceKind.valueOf(rs.getString("kind")), rs.getString("name"), rs.getString("description"), rs.getTimestamp("created_at").toInstant(), @@ -103,13 +117,15 @@ public AdminWorkspaceDetailResponse get(AuthenticatedUser actor, long workspaceI } AdminWorkspaceDetailResponse base = rows.getFirst(); List members = jdbcTemplate.query(""" - select gm.user_id, u.name, u.email, gm.role, u.status, gm.created_at + select u.public_id as user_public_id, u.name, u.email, gm.role, u.status, + gm.created_at from workspace_members gm join users u on u.id = gm.user_id where gm.workspace_id = ? -- enum declaration order is OWNER first (V2), so asc = highest role first order by gm.role asc, gm.user_id - """, (rs, rowNum) -> new AdminWorkspaceMemberResponse(rs.getLong("user_id"), + """, (rs, rowNum) -> new AdminWorkspaceMemberResponse( + rs.getObject("user_public_id", UUID.class), rs.getString("name"), rs.getString("email"), WorkspaceMemberRole.valueOf(rs.getString("role")), UserStatus.valueOf(rs.getString("status")), @@ -125,15 +141,22 @@ private static ApiException workspaceNotFound() { } /** Org tier pinned to their org; another org's id answers 404. */ - private static Long scopeOrgId(AuthenticatedUser actor, Long orgId) { + private Long scopeOrgId(AuthenticatedUser actor, UUID orgId) { + Long requested = orgId == null ? null : jdbcTemplate.query( + "select id from orgs where public_id = ?", + rs -> rs.next() ? rs.getLong(1) : null, orgId); if (!actor.role().isOrgTier()) { - return orgId; + // An id no org has filters to nothing, as a non-matching number did. + if (orgId != null && requested == null) { + return NO_SUCH_ORG; + } + return requested; } if (actor.orgId() == null) { throw new ApiException(HttpStatus.FORBIDDEN, ErrorCodes.ACCESS_DENIED, "접근 권한이 없습니다", "관리 기관이 지정되지 않은 계정입니다."); } - if (orgId != null && !orgId.equals(actor.orgId())) { + if (orgId != null && !actor.orgId().equals(requested)) { throw new ApiException(HttpStatus.NOT_FOUND, ErrorCodes.RESOURCE_NOT_FOUND, "리소스를 찾을 수 없습니다", "해당 기관을 찾을 수 없습니다."); } diff --git a/src/main/java/kr/ac/pusan/pickle/admin/ApprovalContextService.java b/src/main/java/kr/ac/pusan/pickle/admin/ApprovalContextService.java index d80e7039..7f4f405a 100644 --- a/src/main/java/kr/ac/pusan/pickle/admin/ApprovalContextService.java +++ b/src/main/java/kr/ac/pusan/pickle/admin/ApprovalContextService.java @@ -2,6 +2,7 @@ import java.util.List; import java.util.Map; +import java.util.UUID; import java.util.function.Function; import java.util.stream.Collectors; import kr.ac.pusan.pickle.admin.dto.ApprovalContextResponse; @@ -77,7 +78,7 @@ public ApprovalContextService(ApprovalService approvalService, RequestRepository } @Transactional(readOnly = true) - public ApprovalContextResponse context(AuthenticatedUser actor, long requestId) { + public ApprovalContextResponse context(AuthenticatedUser actor, UUID requestId) { Request request = approvalService.findScoped(actor, requestId); // The requester row always exists: accounts are soft-deleted only // (WITHDRAWN + later anonymization keep the row), so @@ -101,7 +102,7 @@ public ApprovalContextResponse context(AuthenticatedUser actor, long requestId) private Applicant applicantPanel(Request request, User applicant) { return new Applicant( - applicant.getId(), + applicant.getPublicId(), applicant.getName(), applicant.getEmail(), applicant.getCreatedAt(), @@ -125,12 +126,13 @@ private WorkspacePanel workspacePanel(Workspace workspace) { .findAllById(members.stream().map(WorkspaceMember::getUserId).toList()) .stream().collect(Collectors.toMap(User::getId, Function.identity())); List memberBriefs = members.stream() - .map(m -> new MemberBrief(m.getUserId(), + .map(m -> new MemberBrief( + users.containsKey(m.getUserId()) ? users.get(m.getUserId()).getPublicId() : null, users.containsKey(m.getUserId()) ? users.get(m.getUserId()).getName() : "탈퇴 회원", m.getRole())) .toList(); List activeVms = vmRepository.findActiveByWorkspaceIdIn(List.of(workspace.getId()), VmStatus.DELETED); - return new WorkspacePanel(workspace.getId(), workspace.getName(), workspace.getKind(), memberBriefs, + return new WorkspacePanel(workspace.getPublicId(), workspace.getName(), workspace.getKind(), memberBriefs, briefs(activeVms), ResourceTotalsResponse.of(activeVms)); } @@ -150,7 +152,7 @@ private List history(Request request) { .map(r -> { RequestReview review = reviews.get(r.getId()); User reviewer = review != null ? reviewers.get(review.getReviewerId()) : null; - return new HistoryEntry(r.getId(), r.getCreatedAt(), r.getStatus(), + return new HistoryEntry(r.getPublicId(), r.getCreatedAt(), r.getStatus(), review != null ? review.getDecision() : null, review != null ? review.getComment() : null, reviewer != null ? reviewer.getName() : null); 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 9f3f0fbf..86351e36 100644 --- a/src/main/java/kr/ac/pusan/pickle/admin/ApprovalService.java +++ b/src/main/java/kr/ac/pusan/pickle/admin/ApprovalService.java @@ -4,6 +4,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.UUID; import java.util.function.Function; import java.util.stream.Collectors; import kr.ac.pusan.pickle.access.ResourceAccessGrant; @@ -27,6 +28,8 @@ import kr.ac.pusan.pickle.user.UserStatus; import kr.ac.pusan.pickle.request.Request; import kr.ac.pusan.pickle.request.RequestAssembler; +import kr.ac.pusan.pickle.orgs.Org; +import kr.ac.pusan.pickle.orgs.OrgRepository; import kr.ac.pusan.pickle.request.RequestRepository; import kr.ac.pusan.pickle.request.RequestSpecs; import kr.ac.pusan.pickle.request.RequestReview; @@ -63,6 +66,7 @@ public class ApprovalService { private final WorkspaceRepository workspaceRepository; private final ResourceAccessGrantRepository grantRepository; private final UserRepository userRepository; + private final OrgRepository orgRepository; private final AuditService auditService; private final NotificationService notificationService; @@ -71,6 +75,7 @@ public ApprovalService(RequestRepository requestRepository, RequestReviewReposit WorkspaceMemberRepository workspaceMemberRepository, WorkspaceRepository workspaceRepository, ResourceAccessGrantRepository grantRepository, UserRepository userRepository, + OrgRepository orgRepository, AuditService auditService, NotificationService notificationService) { this.requestRepository = requestRepository; this.reviewRepository = reviewRepository; @@ -81,16 +86,20 @@ public ApprovalService(RequestRepository requestRepository, RequestReviewReposit this.workspaceRepository = workspaceRepository; this.grantRepository = grantRepository; this.userRepository = userRepository; + this.orgRepository = orgRepository; this.auditService = auditService; this.notificationService = notificationService; } @Transactional(readOnly = true) public PageResponse list(AuthenticatedUser actor, RequestStatus status, - ResourceType type, Long orgId, int page, int size) { + ResourceType type, UUID orgId, int page, int size) { Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "id")); // Org tier is always pinned to their own org; orgId is sys-tier-only. - Long scopedOrgId = actor.role().isOrgTier() ? actor.orgId() : orgId; + // An id no org has filters to nothing, as a non-matching number did. + Long requestedOrgId = orgId == null ? null + : orgRepository.findByPublicId(orgId).map(Org::getId).orElse(-1L); + Long scopedOrgId = actor.role().isOrgTier() ? actor.orgId() : requestedOrgId; if (actor.role().isOrgTier() && scopedOrgId == null) { // Defensive: an org-tier actor without a managed org sees nothing. throw new ApiException(HttpStatus.FORBIDDEN, ErrorCodes.ACCESS_DENIED, @@ -111,12 +120,12 @@ public PageResponse list(AuthenticatedUser actor, Request } @Transactional(readOnly = true) - public RequestDetailResponse get(AuthenticatedUser actor, long requestId) { + public RequestDetailResponse get(AuthenticatedUser actor, UUID requestId) { return assembler.toDetail(findScoped(actor, requestId)); } @Transactional - public RequestDetailResponse approve(AuthenticatedUser actor, long requestId, + public RequestDetailResponse approve(AuthenticatedUser actor, UUID requestId, ApproveRequestRequest form, String ip) { Request request = findScopedWithLock(actor, requestId); requireSubmitted(request); @@ -176,10 +185,10 @@ public void afterCommit() { auditArgs.put("type", request.getResourceType().name()); auditArgs.putAll(created.auditArgs()); auditService.recordAfterCommit(actor.id(), actor.role().name(), AuditService.REQUEST_APPROVE, - "request", request.getId(), auditArgs, ip); + "request", request.getPublicId(), auditArgs, ip); // In-tx insert: the notice exists iff the approval committed. Map notifyArgs = new LinkedHashMap<>(); - notifyArgs.put("requestId", request.getId()); + notifyArgs.put("requestId", request.getPublicId()); notifyArgs.put("type", request.getResourceType().name()); notifyArgs.put("resourceName", created.resourceName()); String reviewComment = Texts.blankToNull(form.comment()); @@ -192,27 +201,27 @@ public void afterCommit() { } @Transactional - public RequestDetailResponse reject(AuthenticatedUser actor, long requestId, + public RequestDetailResponse reject(AuthenticatedUser actor, UUID requestId, RejectRequestRequest form, String ip) { Request request = findScopedWithLock(actor, requestId); requireSubmitted(request); reviewRepository.save(RequestReview.reject(request.getId(), actor.id(), form.comment().strip())); request.setStatus(RequestStatus.REJECTED); auditService.recordAfterCommit(actor.id(), actor.role().name(), AuditService.REQUEST_REJECT, - "request", request.getId(), Map.of("workspaceId", request.getWorkspaceId()), ip); + "request", request.getPublicId(), Map.of("workspaceId", request.getWorkspaceId()), ip); notificationService.publish(request.getRequesterId(), NotificationEvent.REQUEST_REJECTED, - Map.of("requestId", request.getId(), "comment", form.comment().strip(), + Map.of("requestId", request.getPublicId(), "comment", form.comment().strip(), "type", request.getResourceType().name()), null); return assembler.toDetail(request); } /** Org-scoped lookup: unknown id and other-org requests both answer 404. */ - Request findScoped(AuthenticatedUser actor, long requestId) { - return scoped(actor, requestRepository.findById(requestId).orElse(null)); + Request findScoped(AuthenticatedUser actor, UUID requestId) { + return scoped(actor, requestRepository.findByPublicId(requestId).orElse(null)); } - private Request findScopedWithLock(AuthenticatedUser actor, long requestId) { - return scoped(actor, requestRepository.findWithLockById(requestId).orElse(null)); + private Request findScopedWithLock(AuthenticatedUser actor, UUID requestId) { + return scoped(actor, requestRepository.findWithLockByPublicId(requestId).orElse(null)); } private Request scoped(AuthenticatedUser actor, Request request) { diff --git a/src/main/java/kr/ac/pusan/pickle/admin/CapacityTrendService.java b/src/main/java/kr/ac/pusan/pickle/admin/CapacityTrendService.java index b620edd4..c65242ea 100644 --- a/src/main/java/kr/ac/pusan/pickle/admin/CapacityTrendService.java +++ b/src/main/java/kr/ac/pusan/pickle/admin/CapacityTrendService.java @@ -3,6 +3,7 @@ import java.time.Clock; import java.time.LocalDate; import java.util.List; +import java.util.UUID; import kr.ac.pusan.pickle.admin.dto.CapacityTrendPointResponse; import kr.ac.pusan.pickle.admin.dto.CapacityTrendResponse; import kr.ac.pusan.pickle.config.ClockConfig; @@ -79,7 +80,7 @@ public CapacityTrendService(JdbcTemplate jdbcTemplate, AdminSummaryService admin } @Transactional(readOnly = true) - public CapacityTrendResponse trend(AuthenticatedUser actor, int days, Long orgId) { + public CapacityTrendResponse trend(AuthenticatedUser actor, int days, UUID orgId) { Long scopedOrgId = adminSummaryService.resolveOrgId(actor, orgId); LocalDate to = ClockConfig.todayKst(clock); LocalDate from = to.minusDays(days - 1L); diff --git a/src/main/java/kr/ac/pusan/pickle/admin/VmGatewayBlockService.java b/src/main/java/kr/ac/pusan/pickle/admin/VmGatewayBlockService.java index 1aac5039..879a0af9 100644 --- a/src/main/java/kr/ac/pusan/pickle/admin/VmGatewayBlockService.java +++ b/src/main/java/kr/ac/pusan/pickle/admin/VmGatewayBlockService.java @@ -3,11 +3,13 @@ import java.time.Instant; import java.util.HashMap; import java.util.Map; +import java.util.UUID; import kr.ac.pusan.pickle.admin.dto.VmGatewayBlockUpdateRequest; import kr.ac.pusan.pickle.audit.AuditService; import kr.ac.pusan.pickle.common.error.ApiException; import kr.ac.pusan.pickle.common.error.ErrorCodes; import kr.ac.pusan.pickle.security.AuthenticatedUser; +import kr.ac.pusan.pickle.vm.Vm; import kr.ac.pusan.pickle.vm.VmEvent; import kr.ac.pusan.pickle.vm.VmEventRepository; import kr.ac.pusan.pickle.vm.VmEventType; @@ -43,8 +45,9 @@ public VmGatewayBlockService(VmRepository vmRepository, VmEventRepository vmEven @Transactional public kr.ac.pusan.pickle.vm.dto.VmDetailResponse updateBlock(AuthenticatedUser actor, - long vmId, VmGatewayBlockUpdateRequest request, String ip) { - vmRepository.findById(vmId).orElseThrow(VmGatewayBlockService::vmNotFound); + UUID publicVmId, VmGatewayBlockUpdateRequest request, String ip) { + Vm vm = vmRepository.findByPublicId(publicVmId).orElseThrow(VmGatewayBlockService::vmNotFound); + long vmId = vm.getId(); boolean blocked = request.blocked(); // The repo update is CAS-style (flips only when the value differs), so // the rowcount — not a pre-read — decides whether this call is the @@ -65,7 +68,7 @@ public kr.ac.pusan.pickle.vm.dto.VmDetailResponse updateBlock(AuthenticatedUser } auditService.recordAfterCommit(actor.id(), actor.role().name(), blocked ? AuditService.VM_GATEWAY_BLOCK : AuditService.VM_GATEWAY_UNBLOCK, - "vm", vmId, auditDetail, ip); + "vm", vm.getPublicId(), auditDetail, ip); } // Viewer is an admin, not a grantee → myResourceRole null (period-update precedent). return vmQueryService.detailOf(vmRepository.findById(vmId).orElseThrow(), null); diff --git a/src/main/java/kr/ac/pusan/pickle/admin/VmPeriodService.java b/src/main/java/kr/ac/pusan/pickle/admin/VmPeriodService.java index 98a37172..e3e808c7 100644 --- a/src/main/java/kr/ac/pusan/pickle/admin/VmPeriodService.java +++ b/src/main/java/kr/ac/pusan/pickle/admin/VmPeriodService.java @@ -5,6 +5,7 @@ import java.time.LocalDate; import java.util.List; import java.util.Map; +import java.util.UUID; import kr.ac.pusan.pickle.admin.dto.VmPeriodUpdateRequest; import kr.ac.pusan.pickle.audit.AuditService; import kr.ac.pusan.pickle.common.error.ApiException; @@ -59,9 +60,10 @@ public VmPeriodService(VmRepository vmRepository, VmEventRepository vmEventRepos } @Transactional - public VmDetailResponse updatePeriod(AuthenticatedUser actor, long vmId, + public VmDetailResponse updatePeriod(AuthenticatedUser actor, UUID publicVmId, VmPeriodUpdateRequest request, String ip) { - Vm vm = adminVmAccess.requireOrgScopedVm(actor, vmId); + Vm vm = adminVmAccess.requireOrgScopedVm(actor, publicVmId); + long vmId = vm.getId(); LocalDate newStart = request.startDate() != null ? request.startDate() : vm.getStartDate(); validateDates(request.endDate(), newStart); requireNotDeletionBound(vm); @@ -74,7 +76,7 @@ public VmDetailResponse updatePeriod(AuthenticatedUser actor, long vmId, "기간 변경: %s ~ %s → %s ~ %s".formatted( vm.getStartDate(), vm.getEndDate(), newStart, request.endDate()))); auditService.recordAfterCommit(actor.id(), actor.role().name(), - AuditService.VM_PERIOD_UPDATE, "vm", vmId, + AuditService.VM_PERIOD_UPDATE, "vm", vm.getPublicId(), Map.of("old", Map.of("startDate", String.valueOf(vm.getStartDate()), "endDate", String.valueOf(vm.getEndDate())), "new", Map.of("startDate", String.valueOf(newStart), diff --git a/src/main/java/kr/ac/pusan/pickle/admin/dto/AdminNotificationResponse.java b/src/main/java/kr/ac/pusan/pickle/admin/dto/AdminNotificationResponse.java index d68b8c5e..644367f4 100644 --- a/src/main/java/kr/ac/pusan/pickle/admin/dto/AdminNotificationResponse.java +++ b/src/main/java/kr/ac/pusan/pickle/admin/dto/AdminNotificationResponse.java @@ -1,6 +1,7 @@ package kr.ac.pusan.pickle.admin.dto; import java.time.Instant; +import java.util.UUID; import kr.ac.pusan.pickle.notification.NotificationChannel; import kr.ac.pusan.pickle.notification.NotificationImportance; import kr.ac.pusan.pickle.notification.NotificationStatus; @@ -11,7 +12,7 @@ * {@code NotificationView} plus the recipient and email-channel state. */ public record AdminNotificationResponse( - Long id, + UUID id, String event, String title, String body, @@ -19,7 +20,7 @@ public record AdminNotificationResponse( NotificationImportance importance, Instant createdAt, @Nullable Instant readAt, - Long userId, + UUID userId, String userEmail, NotificationChannel channel, NotificationStatus status, diff --git a/src/main/java/kr/ac/pusan/pickle/admin/dto/AdminOsImageResponse.java b/src/main/java/kr/ac/pusan/pickle/admin/dto/AdminOsImageResponse.java index 5758c5fe..43c97114 100644 --- a/src/main/java/kr/ac/pusan/pickle/admin/dto/AdminOsImageResponse.java +++ b/src/main/java/kr/ac/pusan/pickle/admin/dto/AdminOsImageResponse.java @@ -1,6 +1,7 @@ package kr.ac.pusan.pickle.admin.dto; import kr.ac.pusan.pickle.inventory.CatalogStatus; +import java.util.UUID; import kr.ac.pusan.pickle.inventory.OsImage; import org.jspecify.annotations.Nullable; @@ -11,7 +12,7 @@ * (proxmox vmid, node, notes). Spec presets live in {@code VmFlavor}. */ public record AdminOsImageResponse( - long id, + UUID id, String name, String displayName, String osFamily, @@ -19,16 +20,16 @@ public record AdminOsImageResponse( String sshUsername, int version, int proxmoxVmid, - Long nodeId, + UUID nodeId, CatalogStatus status, int minDiskGb, @Nullable String notes) { - public static AdminOsImageResponse from(OsImage image) { - return new AdminOsImageResponse(image.getId(), image.getName(), + public static AdminOsImageResponse from(OsImage image, UUID nodeId) { + return new AdminOsImageResponse(image.getPublicId(), image.getName(), image.getDisplayName(), image.getOsFamily(), image.getOsVersion(), image.getSshUsername(), image.getVersion(), image.getProxmoxVmid(), - image.getNodeId(), image.getStatus(), image.getMinDiskGb(), + nodeId, image.getStatus(), image.getMinDiskGb(), image.getNotes()); } } diff --git a/src/main/java/kr/ac/pusan/pickle/admin/dto/AdminTaskResponse.java b/src/main/java/kr/ac/pusan/pickle/admin/dto/AdminTaskResponse.java index afc4f4a3..5e1a00b4 100644 --- a/src/main/java/kr/ac/pusan/pickle/admin/dto/AdminTaskResponse.java +++ b/src/main/java/kr/ac/pusan/pickle/admin/dto/AdminTaskResponse.java @@ -1,6 +1,7 @@ package kr.ac.pusan.pickle.admin.dto; import java.time.Instant; +import java.util.UUID; import kr.ac.pusan.pickle.provisioning.ProvisioningTask; import kr.ac.pusan.pickle.provisioning.ProvisioningTaskKind; import kr.ac.pusan.pickle.provisioning.ProvisioningTaskStatus; @@ -14,11 +15,11 @@ * nullable per contract (VM rows are permanent, so normally always present). */ public record AdminTaskResponse( - Long taskId, - Long vmId, + UUID taskId, + UUID vmId, @Nullable String vmName, @Nullable String hostname, - @Nullable Long orgId, + @Nullable UUID orgId, @Nullable String orgName, @Nullable String workspaceName, ProvisioningTaskKind kind, @@ -32,13 +33,13 @@ public record AdminTaskResponse( Instant createdAt, Instant updatedAt) { - public static AdminTaskResponse from(ProvisioningTask task, Vm vm, String orgName, - String workspaceName) { + public static AdminTaskResponse from(ProvisioningTask task, Vm vm, UUID vmId, UUID orgId, + String orgName, String workspaceName) { ProvisioningTaskResponse view = ProvisioningTaskResponse.from(task); - return new AdminTaskResponse(task.getId(), task.getVmId(), + return new AdminTaskResponse(task.getPublicId(), vmId, vm == null ? null : vm.getName(), vm == null ? null : vm.getHostname(), - vm == null ? null : vm.getOrgId(), + orgId, orgName, workspaceName, view.kind(), view.status(), view.currentStep(), view.totalSteps(), diff --git a/src/main/java/kr/ac/pusan/pickle/admin/dto/AdminWorkspaceDetailResponse.java b/src/main/java/kr/ac/pusan/pickle/admin/dto/AdminWorkspaceDetailResponse.java index 51f09639..ded280c4 100644 --- a/src/main/java/kr/ac/pusan/pickle/admin/dto/AdminWorkspaceDetailResponse.java +++ b/src/main/java/kr/ac/pusan/pickle/admin/dto/AdminWorkspaceDetailResponse.java @@ -2,6 +2,7 @@ import java.time.Instant; import java.util.List; +import java.util.UUID; import kr.ac.pusan.pickle.workspace.WorkspaceKind; import org.jspecify.annotations.Nullable; @@ -13,7 +14,7 @@ * (a workspace belongs to every org it has requests/VMs in). */ public record AdminWorkspaceDetailResponse( - long id, + UUID id, WorkspaceKind kind, String name, @Nullable String description, diff --git a/src/main/java/kr/ac/pusan/pickle/admin/dto/AdminWorkspaceMemberResponse.java b/src/main/java/kr/ac/pusan/pickle/admin/dto/AdminWorkspaceMemberResponse.java index 9618db5a..f2d09f6c 100644 --- a/src/main/java/kr/ac/pusan/pickle/admin/dto/AdminWorkspaceMemberResponse.java +++ b/src/main/java/kr/ac/pusan/pickle/admin/dto/AdminWorkspaceMemberResponse.java @@ -1,6 +1,7 @@ package kr.ac.pusan.pickle.admin.dto; import java.time.Instant; +import java.util.UUID; import kr.ac.pusan.pickle.workspace.WorkspaceMemberRole; import kr.ac.pusan.pickle.user.UserStatus; @@ -11,7 +12,7 @@ * an admin audit needs to see — with {@code userStatus} carrying the state. */ public record AdminWorkspaceMemberResponse( - long userId, + UUID userId, String name, String email, WorkspaceMemberRole workspaceRole, diff --git a/src/main/java/kr/ac/pusan/pickle/admin/dto/AdminWorkspaceOptionResponse.java b/src/main/java/kr/ac/pusan/pickle/admin/dto/AdminWorkspaceOptionResponse.java index 438dc521..c0db1b65 100644 --- a/src/main/java/kr/ac/pusan/pickle/admin/dto/AdminWorkspaceOptionResponse.java +++ b/src/main/java/kr/ac/pusan/pickle/admin/dto/AdminWorkspaceOptionResponse.java @@ -1,6 +1,7 @@ package kr.ac.pusan.pickle.admin.dto; import java.time.Instant; +import java.util.UUID; import kr.ac.pusan.pickle.workspace.WorkspaceKind; /** @@ -8,6 +9,6 @@ * doubling as the admin workspace-list row since v0.19.0 ({@code kind} and * {@code createdAt} added — additive, picker consumers unaffected). */ -public record AdminWorkspaceOptionResponse(long id, String name, long memberCount, +public record AdminWorkspaceOptionResponse(UUID id, String name, long memberCount, WorkspaceKind kind, Instant createdAt) { } diff --git a/src/main/java/kr/ac/pusan/pickle/admin/dto/ApprovalContextResponse.java b/src/main/java/kr/ac/pusan/pickle/admin/dto/ApprovalContextResponse.java index a03a5540..d273d3e3 100644 --- a/src/main/java/kr/ac/pusan/pickle/admin/dto/ApprovalContextResponse.java +++ b/src/main/java/kr/ac/pusan/pickle/admin/dto/ApprovalContextResponse.java @@ -3,6 +3,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import java.time.Instant; import java.util.List; +import java.util.UUID; import kr.ac.pusan.pickle.workspace.WorkspaceKind; import kr.ac.pusan.pickle.workspace.WorkspaceMemberRole; import kr.ac.pusan.pickle.request.ReviewDecision; @@ -24,7 +25,7 @@ public record ApprovalContextResponse( String guidance) { public record Applicant( - Long id, + UUID id, String name, String email, Instant signupAt, @@ -36,7 +37,7 @@ public record Resources(List activeVms, ResourceTotalsResponse } public record WorkspacePanel( - Long id, + UUID id, String name, WorkspaceKind kind, List members, @@ -44,11 +45,11 @@ public record WorkspacePanel( ResourceTotalsResponse totals) { } - public record MemberBrief(Long userId, String name, WorkspaceMemberRole role) { + public record MemberBrief(UUID userId, String name, WorkspaceMemberRole role) { } public record HistoryEntry( - Long requestId, + UUID requestId, Instant submittedAt, RequestStatus status, @Nullable ReviewDecision decision, diff --git a/src/main/java/kr/ac/pusan/pickle/admin/dto/ApproveVmRequestSpec.java b/src/main/java/kr/ac/pusan/pickle/admin/dto/ApproveVmRequestSpec.java index 83527456..c183e602 100644 --- a/src/main/java/kr/ac/pusan/pickle/admin/dto/ApproveVmRequestSpec.java +++ b/src/main/java/kr/ac/pusan/pickle/admin/dto/ApproveVmRequestSpec.java @@ -3,6 +3,7 @@ import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; +import java.util.UUID; import org.jspecify.annotations.Nullable; /** @@ -24,7 +25,7 @@ public record ApproveVmRequestSpec( Integer grantedDiskGb, @NotNull(message = "부여 OS 이미지(grantedImageId)를 지정해 주세요.") - Long grantedImageId, + UUID grantedImageId, // 최종 호스트명(슬러그, v0.12.0) — null/공백이면 기존처럼 자동 생성. // Blank ≡ null이므로 패턴이 공백을 허용하고, 예약어·중복(파기 VM 포함)은 @@ -34,5 +35,5 @@ public record ApproveVmRequestSpec( @Nullable String grantedSlug, /** null이면 자동 배치. */ - @Nullable Long nodeId) { + @Nullable UUID nodeId) { } diff --git a/src/main/java/kr/ac/pusan/pickle/admin/dto/CreateOrgRequest.java b/src/main/java/kr/ac/pusan/pickle/admin/dto/CreateOrgRequest.java index c660be29..35da964f 100644 --- a/src/main/java/kr/ac/pusan/pickle/admin/dto/CreateOrgRequest.java +++ b/src/main/java/kr/ac/pusan/pickle/admin/dto/CreateOrgRequest.java @@ -1,9 +1,7 @@ package kr.ac.pusan.pickle.admin.dto; import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; -import kr.ac.pusan.pickle.common.validation.Slugs; import org.jspecify.annotations.Nullable; /** Contract: POST /admin/orgs body. */ @@ -12,10 +10,6 @@ public record CreateOrgRequest( @Size(max = 100, message = "기관 이름은 100자 이하여야 합니다.") String name, - @NotBlank(message = "slug를 입력해 주세요.") - @Pattern(regexp = Slugs.PATTERN, message = Slugs.MESSAGE) - String slug, - @Size(max = 500, message = "설명은 500자 이하여야 합니다.") @Nullable String description) { } diff --git a/src/main/java/kr/ac/pusan/pickle/admin/dto/DriftFindingResponse.java b/src/main/java/kr/ac/pusan/pickle/admin/dto/DriftFindingResponse.java index 39a770a7..2e1acac0 100644 --- a/src/main/java/kr/ac/pusan/pickle/admin/dto/DriftFindingResponse.java +++ b/src/main/java/kr/ac/pusan/pickle/admin/dto/DriftFindingResponse.java @@ -1,6 +1,7 @@ package kr.ac.pusan.pickle.admin.dto; import java.time.Instant; +import java.util.UUID; import kr.ac.pusan.pickle.provisioning.DriftFinding; import kr.ac.pusan.pickle.provisioning.DriftFindingKind; import kr.ac.pusan.pickle.provisioning.DriftFindingStatus; @@ -9,9 +10,9 @@ /** Contract {@code DriftFindingView}. */ public record DriftFindingResponse( - Long id, + UUID id, DriftFindingKind kind, - @Nullable Long vmId, + @Nullable UUID vmId, @Nullable String vmName, @Nullable Integer proxmoxVmid, @Nullable String nodeName, @@ -21,16 +22,16 @@ public record DriftFindingResponse( Instant firstSeenAt, Instant lastSeenAt, @Nullable Instant resolvedAt, - @Nullable Long resolvedById, + @Nullable UUID resolvedById, @Nullable String resolvedByEmail, @Nullable String resolutionNote) { public static DriftFindingResponse from(DriftFinding finding, JsonNode detail, - String resolvedByEmail, String vmName) { - return new DriftFindingResponse(finding.getId(), finding.getKind(), finding.getVmId(), + UUID vmId, String vmName, UUID resolvedById, String resolvedByEmail) { + return new DriftFindingResponse(finding.getPublicId(), finding.getKind(), vmId, vmName, finding.getProxmoxVmid(), finding.getNodeName(), finding.getSummary(), detail, finding.getStatus(), finding.getFirstSeenAt(), finding.getLastSeenAt(), - finding.getResolvedAt(), finding.getResolvedBy(), resolvedByEmail, + finding.getResolvedAt(), resolvedById, resolvedByEmail, finding.getResolutionNote()); } } diff --git a/src/main/java/kr/ac/pusan/pickle/admin/dto/IpAllocationResponse.java b/src/main/java/kr/ac/pusan/pickle/admin/dto/IpAllocationResponse.java index e0e5f606..4f1ac029 100644 --- a/src/main/java/kr/ac/pusan/pickle/admin/dto/IpAllocationResponse.java +++ b/src/main/java/kr/ac/pusan/pickle/admin/dto/IpAllocationResponse.java @@ -1,6 +1,7 @@ package kr.ac.pusan.pickle.admin.dto; import java.time.Instant; +import java.util.UUID; import kr.ac.pusan.pickle.ipam.AllocationStatus; import kr.ac.pusan.pickle.ipam.IpAllocation; import kr.ac.pusan.pickle.vm.Vm; @@ -12,20 +13,21 @@ * permanent, so RELEASED history rows normally still resolve them). */ public record IpAllocationResponse( - Long id, - Long poolId, + UUID id, + UUID poolId, String poolName, String ip, - @Nullable Long vmId, + @Nullable UUID vmId, @Nullable String vmName, @Nullable String hostname, AllocationStatus status, Instant allocatedAt, @Nullable Instant releasedAt) { - public static IpAllocationResponse from(IpAllocation allocation, String poolName, Vm vm) { - return new IpAllocationResponse(allocation.getId(), allocation.getPoolId(), poolName, - allocation.getIp(), allocation.getVmId(), + public static IpAllocationResponse from(IpAllocation allocation, UUID poolId, String poolName, + UUID vmId, Vm vm) { + return new IpAllocationResponse(allocation.getPublicId(), poolId, poolName, + allocation.getIp(), vmId, vm == null ? null : vm.getName(), vm == null ? null : vm.getHostname(), allocation.getStatus(), allocation.getAllocatedAt(), allocation.getReleasedAt()); diff --git a/src/main/java/kr/ac/pusan/pickle/admin/dto/IpPoolSummaryResponse.java b/src/main/java/kr/ac/pusan/pickle/admin/dto/IpPoolSummaryResponse.java index a17f6a61..d02fd222 100644 --- a/src/main/java/kr/ac/pusan/pickle/admin/dto/IpPoolSummaryResponse.java +++ b/src/main/java/kr/ac/pusan/pickle/admin/dto/IpPoolSummaryResponse.java @@ -1,17 +1,18 @@ package kr.ac.pusan.pickle.admin.dto; import kr.ac.pusan.pickle.ipam.IpamService; +import java.util.UUID; /** Contract schema {@code IpPoolSummary} — pool occupancy on the node view. */ public record IpPoolSummaryResponse( - Long id, + UUID id, String name, String cidr, long allocatedCount, long freeCount) { public static IpPoolSummaryResponse from(IpamService.PoolUsage usage) { - return new IpPoolSummaryResponse(usage.pool().getId(), usage.pool().getName(), + return new IpPoolSummaryResponse(usage.pool().getPublicId(), usage.pool().getName(), usage.pool().getCidr(), usage.allocatedCount(), usage.freeCount()); } } diff --git a/src/main/java/kr/ac/pusan/pickle/admin/dto/NodeLiveResponse.java b/src/main/java/kr/ac/pusan/pickle/admin/dto/NodeLiveResponse.java index c32e4e83..af3ef7c1 100644 --- a/src/main/java/kr/ac/pusan/pickle/admin/dto/NodeLiveResponse.java +++ b/src/main/java/kr/ac/pusan/pickle/admin/dto/NodeLiveResponse.java @@ -2,6 +2,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import java.time.Instant; +import java.util.UUID; import org.jspecify.annotations.Nullable; /** @@ -21,7 +22,7 @@ * the summary itself, in {@code liveCoverage}. */ public record NodeLiveResponse( - long nodeId, + UUID nodeId, String name, @Schema(description = "true = 이 노드가 상태 조회에 응답함. false = 응답하지 않음이며 나머지 필드는 모두 null." + " true여도 스토리지 조회는 별도 권한이라 storage* 필드는 null일 수 있음") @@ -34,7 +35,7 @@ public record NodeLiveResponse( @Nullable Instant checkedAt) { /** The unreachable answer: identity only, and the console renders the gap. */ - public static NodeLiveResponse unreachable(long nodeId, String name) { + public static NodeLiveResponse unreachable(UUID nodeId, String name) { return new NodeLiveResponse(nodeId, name, false, null, null, null, null, null, null); } } diff --git a/src/main/java/kr/ac/pusan/pickle/admin/dto/NodeSummaryResponse.java b/src/main/java/kr/ac/pusan/pickle/admin/dto/NodeSummaryResponse.java index f453b573..a36f4ac4 100644 --- a/src/main/java/kr/ac/pusan/pickle/admin/dto/NodeSummaryResponse.java +++ b/src/main/java/kr/ac/pusan/pickle/admin/dto/NodeSummaryResponse.java @@ -1,6 +1,7 @@ package kr.ac.pusan.pickle.admin.dto; import io.swagger.v3.oas.annotations.media.Schema; +import java.util.UUID; import kr.ac.pusan.pickle.inventory.NodeStatus; import org.jspecify.annotations.Nullable; @@ -10,7 +11,7 @@ * {@code AdminNodeQueryService}. */ public record NodeSummaryResponse( - Long id, + UUID id, String name, NodeStatus status, int cpuThreads, diff --git a/src/main/java/kr/ac/pusan/pickle/admin/dto/OrgDashboardSummaryResponse.java b/src/main/java/kr/ac/pusan/pickle/admin/dto/OrgDashboardSummaryResponse.java index 736ec4a2..bfb6f4d6 100644 --- a/src/main/java/kr/ac/pusan/pickle/admin/dto/OrgDashboardSummaryResponse.java +++ b/src/main/java/kr/ac/pusan/pickle/admin/dto/OrgDashboardSummaryResponse.java @@ -3,6 +3,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import java.util.List; import java.util.Map; +import java.util.UUID; import org.jspecify.annotations.Nullable; /** Contract schema {@code OrgDashboardSummary} (ORG_ADMIN home / SYS_ADMIN org drill-in). */ @@ -31,7 +32,7 @@ public record Resource( String guidance) { } - public record TopWorkspace(Long workspaceId, String name, long vmCount) { + public record TopWorkspace(UUID workspaceId, String name, long vmCount) { } public record Attention(long failedTaskCount, long needsAdminVmCount, long expiredVmCount) { diff --git a/src/main/java/kr/ac/pusan/pickle/admin/dto/OrgDetailResponse.java b/src/main/java/kr/ac/pusan/pickle/admin/dto/OrgDetailResponse.java index ec8f97e1..261c7759 100644 --- a/src/main/java/kr/ac/pusan/pickle/admin/dto/OrgDetailResponse.java +++ b/src/main/java/kr/ac/pusan/pickle/admin/dto/OrgDetailResponse.java @@ -1,22 +1,22 @@ package kr.ac.pusan.pickle.admin.dto; import java.time.Instant; +import java.util.UUID; import kr.ac.pusan.pickle.orgs.Org; import kr.ac.pusan.pickle.orgs.OrgStatus; import org.jspecify.annotations.Nullable; /** Contract schema {@code OrgDetailResponse} (v0.15.0 adds {@code hidden}). */ public record OrgDetailResponse( - Long id, + UUID id, String name, - String slug, @Nullable String description, OrgStatus status, boolean hidden, Instant createdAt) { public static OrgDetailResponse from(Org org) { - return new OrgDetailResponse(org.getId(), org.getName(), org.getSlug(), org.getDescription(), + return new OrgDetailResponse(org.getPublicId(), org.getName(), org.getDescription(), org.getStatus(), org.isHidden(), org.getCreatedAt()); } } diff --git a/src/main/java/kr/ac/pusan/pickle/admin/dto/SystemDashboardSummaryResponse.java b/src/main/java/kr/ac/pusan/pickle/admin/dto/SystemDashboardSummaryResponse.java index d7e004c3..95dfe881 100644 --- a/src/main/java/kr/ac/pusan/pickle/admin/dto/SystemDashboardSummaryResponse.java +++ b/src/main/java/kr/ac/pusan/pickle/admin/dto/SystemDashboardSummaryResponse.java @@ -3,6 +3,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import java.util.List; import java.util.Map; +import java.util.UUID; import kr.ac.pusan.pickle.inventory.NodeStatus; /** Contract schema {@code SystemDashboardSummary} (SYS_ADMIN only). */ @@ -38,7 +39,7 @@ public record LiveCoverage( /** Per-node allocation ratios (same aggregates as {@code GET /admin/nodes}). */ public record NodeRatio( - Long id, + UUID id, String name, NodeStatus status, double cpuOvercommitRatio, @@ -54,7 +55,7 @@ public record Tasks( } public record IpPoolUsage( - Long id, + UUID id, String name, String cidr, long allocatedCount, diff --git a/src/main/java/kr/ac/pusan/pickle/admin/dto/UpdateUserAdminRequest.java b/src/main/java/kr/ac/pusan/pickle/admin/dto/UpdateUserAdminRequest.java index f1a67afe..d6079ccb 100644 --- a/src/main/java/kr/ac/pusan/pickle/admin/dto/UpdateUserAdminRequest.java +++ b/src/main/java/kr/ac/pusan/pickle/admin/dto/UpdateUserAdminRequest.java @@ -1,6 +1,7 @@ package kr.ac.pusan.pickle.admin.dto; import kr.ac.pusan.pickle.user.UserRole; +import java.util.UUID; import org.jspecify.annotations.Nullable; /** @@ -9,7 +10,7 @@ * {@code orgId}; other roles require it to be null/absent — validated in the * service. */ -public record UpdateUserAdminRequest(UserRole role, @Nullable Long orgId) { +public record UpdateUserAdminRequest(UserRole role, @Nullable UUID orgId) { @io.swagger.v3.oas.annotations.media.Schema(hidden = true) public boolean isEmpty() { diff --git a/src/main/java/kr/ac/pusan/pickle/admin/dto/UserAdminDetailResponse.java b/src/main/java/kr/ac/pusan/pickle/admin/dto/UserAdminDetailResponse.java index fbb5be3d..4a003f40 100644 --- a/src/main/java/kr/ac/pusan/pickle/admin/dto/UserAdminDetailResponse.java +++ b/src/main/java/kr/ac/pusan/pickle/admin/dto/UserAdminDetailResponse.java @@ -2,6 +2,7 @@ import java.time.Instant; import java.util.List; +import java.util.UUID; import kr.ac.pusan.pickle.user.UserRole; import kr.ac.pusan.pickle.user.UserStatus; import kr.ac.pusan.pickle.user.dto.UserProfileResponse; @@ -12,11 +13,11 @@ * base fields are flattened in so the JSON matches the contract's allOf. */ public record UserAdminDetailResponse( - Long id, + UUID id, String email, String name, UserRole role, - @Nullable Long orgId, + @Nullable UUID orgId, UserStatus status, boolean mfaEnabled, Instant createdAt, diff --git a/src/main/java/kr/ac/pusan/pickle/admin/dto/UserAdminViewResponse.java b/src/main/java/kr/ac/pusan/pickle/admin/dto/UserAdminViewResponse.java index 0f9a89a8..9e517818 100644 --- a/src/main/java/kr/ac/pusan/pickle/admin/dto/UserAdminViewResponse.java +++ b/src/main/java/kr/ac/pusan/pickle/admin/dto/UserAdminViewResponse.java @@ -1,17 +1,18 @@ package kr.ac.pusan.pickle.admin.dto; import java.time.Instant; +import java.util.UUID; import kr.ac.pusan.pickle.user.UserRole; import kr.ac.pusan.pickle.user.UserStatus; import org.jspecify.annotations.Nullable; /** Contract schema {@code UserAdminView} — admin user-list item. */ public record UserAdminViewResponse( - Long id, + UUID id, String email, String name, UserRole role, - @Nullable Long orgId, + @Nullable UUID orgId, UserStatus status, boolean mfaEnabled, Instant createdAt) { diff --git a/src/main/java/kr/ac/pusan/pickle/admin/dto/UserStatusChangeResponse.java b/src/main/java/kr/ac/pusan/pickle/admin/dto/UserStatusChangeResponse.java index d48fd122..86344fa5 100644 --- a/src/main/java/kr/ac/pusan/pickle/admin/dto/UserStatusChangeResponse.java +++ b/src/main/java/kr/ac/pusan/pickle/admin/dto/UserStatusChangeResponse.java @@ -1,6 +1,7 @@ package kr.ac.pusan.pickle.admin.dto; import java.time.Instant; +import java.util.UUID; import kr.ac.pusan.pickle.user.UserStatus; import org.jspecify.annotations.Nullable; @@ -8,7 +9,7 @@ public record UserStatusChangeResponse( UserStatus fromStatus, UserStatus toStatus, - @Nullable Long actorId, + @Nullable UUID actorId, @Nullable String actorEmail, @Nullable String reason, Instant changedAt) { diff --git a/src/main/java/kr/ac/pusan/pickle/admin/dto/VmBriefResponse.java b/src/main/java/kr/ac/pusan/pickle/admin/dto/VmBriefResponse.java index 2cb687ac..12f3c2b5 100644 --- a/src/main/java/kr/ac/pusan/pickle/admin/dto/VmBriefResponse.java +++ b/src/main/java/kr/ac/pusan/pickle/admin/dto/VmBriefResponse.java @@ -1,13 +1,14 @@ package kr.ac.pusan.pickle.admin.dto; import java.time.LocalDate; +import java.util.UUID; import kr.ac.pusan.pickle.vm.Vm; import kr.ac.pusan.pickle.vm.VmStatus; import org.jspecify.annotations.Nullable; /** Contract schema {@code VmBrief} (approval-context VM line). */ public record VmBriefResponse( - Long id, + UUID id, String name, VmStatus status, int vcpu, @@ -16,7 +17,7 @@ public record VmBriefResponse( @Nullable LocalDate endDate) { public static VmBriefResponse from(Vm vm) { - return new VmBriefResponse(vm.getId(), vm.getName(), vm.getStatus(), + return new VmBriefResponse(vm.getPublicId(), vm.getName(), vm.getStatus(), vm.getVcpu(), vm.getMemoryMb(), vm.getDiskGb(), vm.getEndDate()); } } diff --git a/src/main/java/kr/ac/pusan/pickle/announcement/Announcement.java b/src/main/java/kr/ac/pusan/pickle/announcement/Announcement.java index 9e9cef25..9f1e0f08 100644 --- a/src/main/java/kr/ac/pusan/pickle/announcement/Announcement.java +++ b/src/main/java/kr/ac/pusan/pickle/announcement/Announcement.java @@ -9,7 +9,10 @@ import jakarta.persistence.Id; import jakarta.persistence.Table; import java.time.Instant; +import java.util.UUID; import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; /** * One admin broadcast (V16). The recipient set is snapshotted at send time as @@ -24,6 +27,14 @@ public class Announcement { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + /** + * The identifier this row wears outside the API boundary. Internal joins, + * sorts and foreign keys keep using {@link #id}. + */ + @JdbcTypeCode(SqlTypes.UUID) + @Column(name = "public_id", nullable = false, updatable = false, unique = true) + private UUID publicId = UUID.randomUUID(); + @Column(name = "author_id", nullable = false) private Long authorId; @@ -67,6 +78,10 @@ public Long getId() { return id; } + public UUID getPublicId() { + return publicId; + } + public Long getAuthorId() { return authorId; } diff --git a/src/main/java/kr/ac/pusan/pickle/announcement/AnnouncementQueryService.java b/src/main/java/kr/ac/pusan/pickle/announcement/AnnouncementQueryService.java index c0c15519..8c998e48 100644 --- a/src/main/java/kr/ac/pusan/pickle/announcement/AnnouncementQueryService.java +++ b/src/main/java/kr/ac/pusan/pickle/announcement/AnnouncementQueryService.java @@ -16,9 +16,15 @@ public class AnnouncementQueryService { private final AnnouncementRepository announcementRepository; + private final kr.ac.pusan.pickle.orgs.OrgRepository orgRepository; + private final kr.ac.pusan.pickle.workspace.WorkspaceRepository workspaceRepository; - public AnnouncementQueryService(AnnouncementRepository announcementRepository) { + public AnnouncementQueryService(AnnouncementRepository announcementRepository, + kr.ac.pusan.pickle.orgs.OrgRepository orgRepository, + kr.ac.pusan.pickle.workspace.WorkspaceRepository workspaceRepository) { this.announcementRepository = announcementRepository; + this.orgRepository = orgRepository; + this.workspaceRepository = workspaceRepository; } @Transactional(readOnly = true) @@ -34,7 +40,20 @@ public PageResponse list(AuthenticatedUser actor, int page, in result = announcementRepository.findVisibleToOrgAdmin(AnnouncementScope.ALL, actor.orgId(), PageRequest.of(page, size)); } - return PageResponse.of(result.getContent().stream().map(AnnouncementView::from).toList(), - result); + return PageResponse.of(result.getContent().stream().map(this::toView).toList(), result); + } + + /** + * An announcement names its scope target by public id, so the org or + * workspace row behind it is read here rather than in the view. + */ + private AnnouncementView toView(Announcement announcement) { + return AnnouncementView.from(announcement, + announcement.getOrgId() == null ? null + : orgRepository.findById(announcement.getOrgId()) + .map(kr.ac.pusan.pickle.orgs.Org::getPublicId).orElse(null), + announcement.getWorkspaceId() == null ? null + : workspaceRepository.findById(announcement.getWorkspaceId()) + .map(kr.ac.pusan.pickle.workspace.Workspace::getPublicId).orElse(null)); } } diff --git a/src/main/java/kr/ac/pusan/pickle/announcement/AnnouncementRepository.java b/src/main/java/kr/ac/pusan/pickle/announcement/AnnouncementRepository.java index ff5a00a5..51d275cc 100644 --- a/src/main/java/kr/ac/pusan/pickle/announcement/AnnouncementRepository.java +++ b/src/main/java/kr/ac/pusan/pickle/announcement/AnnouncementRepository.java @@ -1,6 +1,8 @@ package kr.ac.pusan.pickle.announcement; import org.springframework.data.domain.Page; +import java.util.Optional; +import java.util.UUID; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -8,6 +10,9 @@ public interface AnnouncementRepository extends JpaRepository { + /** Resolution of the identifier this row wears outside the API boundary. */ + Optional findByPublicId(UUID publicId); + Page findAllByOrderByIdDesc(Pageable pageable); /** diff --git a/src/main/java/kr/ac/pusan/pickle/announcement/AnnouncementService.java b/src/main/java/kr/ac/pusan/pickle/announcement/AnnouncementService.java index 1f91c6ab..5f5e7797 100644 --- a/src/main/java/kr/ac/pusan/pickle/announcement/AnnouncementService.java +++ b/src/main/java/kr/ac/pusan/pickle/announcement/AnnouncementService.java @@ -10,8 +10,10 @@ import kr.ac.pusan.pickle.common.error.ApiException; import kr.ac.pusan.pickle.common.error.ErrorCodes; import kr.ac.pusan.pickle.common.error.FieldValidationError; +import kr.ac.pusan.pickle.workspace.Workspace; import kr.ac.pusan.pickle.workspace.WorkspaceRepository; import kr.ac.pusan.pickle.notification.NotificationEvent; +import kr.ac.pusan.pickle.orgs.Org; import kr.ac.pusan.pickle.orgs.OrgMembershipSql; import kr.ac.pusan.pickle.orgs.OrgRepository; import kr.ac.pusan.pickle.security.AuthenticatedUser; @@ -68,6 +70,13 @@ public AnnouncementView create(AuthenticatedUser actor, AnnouncementCreateReques List errors = new ArrayList<>(); Long orgId = null; Long workspaceId = null; + // The scope target arrives as a public id; the row behind it is what the + // announcement stores, and an id no row has is a validation error below. + Long requestedOrgId = request.orgId() == null ? null + : orgRepository.findByPublicId(request.orgId()).map(Org::getId).orElse(null); + Long requestedWorkspaceId = request.workspaceId() == null ? null + : workspaceRepository.findByPublicId(request.workspaceId()) + .map(Workspace::getId).orElse(null); switch (scope) { case ALL -> { if (actor.role() != UserRole.SYS_ADMIN) { @@ -90,7 +99,7 @@ public AnnouncementView create(AuthenticatedUser actor, AnnouncementCreateReques throw new ApiException(HttpStatus.FORBIDDEN, ErrorCodes.ACCESS_DENIED, "접근 권한이 없습니다", "관리 기관이 지정되지 않은 계정입니다."); } - if (request.orgId() != null && !request.orgId().equals(actor.orgId())) { + if (request.orgId() != null && !actor.orgId().equals(requestedOrgId)) { errors.add(new FieldValidationError("orgId", "자기 기관에만 기관 공지를 발송할 수 있습니다.")); } @@ -99,7 +108,7 @@ public AnnouncementView create(AuthenticatedUser actor, AnnouncementCreateReques if (request.orgId() == null) { errors.add(new FieldValidationError("orgId", "기관 공지에는 대상 기관이 필요합니다.")); } - orgId = request.orgId(); + orgId = requestedOrgId; } } case WORKSPACE -> { @@ -109,20 +118,20 @@ public AnnouncementView create(AuthenticatedUser actor, AnnouncementCreateReques if (request.workspaceId() == null) { errors.add(new FieldValidationError("workspaceId", "워크스페이스 공지에는 대상 워크스페이스가 필요합니다.")); } - workspaceId = request.workspaceId(); + workspaceId = requestedWorkspaceId; } } if (!errors.isEmpty()) { throw ApiException.validationFailed(errors); } - if (scope == AnnouncementScope.ORG && !orgRepository.existsById(orgId)) { + if (scope == AnnouncementScope.ORG && (orgId == null || !orgRepository.existsById(orgId))) { throw notFound("해당 기관이 존재하지 않습니다."); } if (scope == AnnouncementScope.WORKSPACE) { // Unknown workspace and (for ORG_ADMIN) a workspace without resources in // their org answer the same 404 — workspace existence stays private. - if (!workspaceRepository.existsByIdAndDeletedAtIsNull(workspaceId) + if (workspaceId == null || !workspaceRepository.existsByIdAndDeletedAtIsNull(workspaceId) || (actor.role() == UserRole.ORG_ADMIN && !workspaceLinkedToOrg(workspaceId, actor.orgId()))) { throw notFound("해당 워크스페이스가 존재하지 않습니다."); @@ -141,9 +150,17 @@ public AnnouncementView create(AuthenticatedUser actor, AnnouncementCreateReques int recipients = fanOut(announcement); announcement.setRecipientCount(recipients); auditService.recordAfterCommit(actor.id(), actor.role().name(), - AuditService.ANNOUNCEMENT_CREATE, "announcement", announcement.getId(), + AuditService.ANNOUNCEMENT_CREATE, "announcement", announcement.getPublicId(), Map.of("scope", scope.name(), "recipientCount", recipients), ip); - return AnnouncementView.from(announcement); + // The scope target is what was stored, not what the body named: an + // ORG_ADMIN's org announcement takes its org from the actor. + return AnnouncementView.from(announcement, + announcement.getOrgId() == null ? null + : orgRepository.findById(announcement.getOrgId()).map(Org::getPublicId) + .orElse(null), + announcement.getWorkspaceId() == null ? null + : workspaceRepository.findById(announcement.getWorkspaceId()) + .map(Workspace::getPublicId).orElse(null)); } /** diff --git a/src/main/java/kr/ac/pusan/pickle/announcement/dto/AnnouncementCreateRequest.java b/src/main/java/kr/ac/pusan/pickle/announcement/dto/AnnouncementCreateRequest.java index aef5611a..d819628e 100644 --- a/src/main/java/kr/ac/pusan/pickle/announcement/dto/AnnouncementCreateRequest.java +++ b/src/main/java/kr/ac/pusan/pickle/announcement/dto/AnnouncementCreateRequest.java @@ -3,6 +3,7 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; +import java.util.UUID; import kr.ac.pusan.pickle.announcement.AnnouncementScope; import org.jspecify.annotations.Nullable; @@ -16,6 +17,6 @@ public record AnnouncementCreateRequest( String body, @NotNull(message = "공지 범위를 선택해 주세요.") AnnouncementScope scope, - @Nullable Long orgId, - @Nullable Long workspaceId) { + @Nullable UUID orgId, + @Nullable UUID workspaceId) { } diff --git a/src/main/java/kr/ac/pusan/pickle/announcement/dto/AnnouncementView.java b/src/main/java/kr/ac/pusan/pickle/announcement/dto/AnnouncementView.java index b597d650..9f121923 100644 --- a/src/main/java/kr/ac/pusan/pickle/announcement/dto/AnnouncementView.java +++ b/src/main/java/kr/ac/pusan/pickle/announcement/dto/AnnouncementView.java @@ -1,23 +1,24 @@ package kr.ac.pusan.pickle.announcement.dto; import java.time.Instant; +import java.util.UUID; import kr.ac.pusan.pickle.announcement.Announcement; import kr.ac.pusan.pickle.announcement.AnnouncementScope; import org.jspecify.annotations.Nullable; /** Contract {@code AnnouncementView}: recipientCount is the send-time snapshot. */ public record AnnouncementView( - long id, + UUID id, String title, AnnouncementScope scope, - @Nullable Long orgId, - @Nullable Long workspaceId, + @Nullable UUID orgId, + @Nullable UUID workspaceId, int recipientCount, Instant createdAt) { - public static AnnouncementView from(Announcement announcement) { - return new AnnouncementView(announcement.getId(), announcement.getTitle(), - announcement.getScope(), announcement.getOrgId(), announcement.getWorkspaceId(), + public static AnnouncementView from(Announcement announcement, UUID orgId, UUID workspaceId) { + return new AnnouncementView(announcement.getPublicId(), announcement.getTitle(), + announcement.getScope(), orgId, workspaceId, announcement.getRecipientCount(), announcement.getCreatedAt()); } } diff --git a/src/main/java/kr/ac/pusan/pickle/audit/AdminAuditController.java b/src/main/java/kr/ac/pusan/pickle/audit/AdminAuditController.java index 3c5c3fed..f42b9517 100644 --- a/src/main/java/kr/ac/pusan/pickle/audit/AdminAuditController.java +++ b/src/main/java/kr/ac/pusan/pickle/audit/AdminAuditController.java @@ -3,6 +3,7 @@ import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import java.time.LocalDate; +import java.util.UUID; import kr.ac.pusan.pickle.audit.dto.AuditLogViewResponse; import kr.ac.pusan.pickle.common.web.PageResponse; import kr.ac.pusan.pickle.security.AuthenticatedUser; @@ -41,7 +42,7 @@ public PageResponse listAuditLogs( LocalDate from, @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to, - @RequestParam(required = false) Long orgId, + @RequestParam(required = false) UUID orgId, @RequestParam(defaultValue = "0") @Min(0) int page, @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) { return auditQueryService.adminAudit(principal, actorEmail, action, targetType, targetId, diff --git a/src/main/java/kr/ac/pusan/pickle/audit/AuditQueryService.java b/src/main/java/kr/ac/pusan/pickle/audit/AuditQueryService.java index a83a9772..13e3e43b 100644 --- a/src/main/java/kr/ac/pusan/pickle/audit/AuditQueryService.java +++ b/src/main/java/kr/ac/pusan/pickle/audit/AuditQueryService.java @@ -6,6 +6,7 @@ import java.time.ZoneId; import java.util.ArrayList; import java.util.List; +import java.util.UUID; import kr.ac.pusan.pickle.audit.dto.ActivityEntryResponse; import kr.ac.pusan.pickle.audit.dto.AuditLogViewResponse; import kr.ac.pusan.pickle.common.error.ApiException; @@ -35,6 +36,12 @@ */ @Service public class AuditQueryService { + /** + * The scope an id no org has resolves to: a filter value no row carries, so + * the page comes back empty exactly as a non-matching number made it. + */ + private static final Long NO_SUCH_ORG = -1L; + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); @@ -81,9 +88,9 @@ public PageResponse myActivity(long actorId, String actio select a.id, a.action, a.target_type, a.target_id, a.detail, a.ip, a.created_at from audit_logs a""" + where + " order by a.created_at desc, a.id desc limit ? offset ?", - (rs, rowNum) -> new ActivityEntryResponse(rs.getLong("id"), + (rs, rowNum) -> new ActivityEntryResponse(String.valueOf(rs.getLong("id")), rs.getString("action"), rs.getString("target_type"), - targetIdOf(rs.getObject("target_id", Long.class)), + rs.getString("target_id"), detailOf(rs.getString("detail")), rs.getString("ip"), rs.getObject("created_at", OffsetDateTime.class).toInstant()), params.toArray()); @@ -93,7 +100,7 @@ public PageResponse myActivity(long actorId, String actio @Transactional(readOnly = true) public PageResponse adminAudit(AuthenticatedUser actor, String actorEmail, String action, String targetType, String targetId, - LocalDate from, LocalDate to, Long orgId, int page, int size) { + LocalDate from, LocalDate to, UUID orgId, int page, int size) { Long scopedOrgId = scopeOrgId(actor, orgId); StringBuilder where = new StringBuilder(" where 1 = 1"); List params = new ArrayList<>(); @@ -126,16 +133,20 @@ public PageResponse adminAudit(AuthenticatedUser actor, long total = queryCount("select count(*)" + base + where, params); params.add(size); params.add((long) page * size); - List content = jdbcTemplate.query("select a.id, a.actor_id, " + // u.public_id, not a.actor_id: the actor is reported by the identifier + // the API names accounts with. actor_id itself stays the internal key + // the join runs on. + List content = jdbcTemplate.query("select a.id, u.public_id " + + "as actor_public_id, " + "a.actor_role, a.action, a.target_type, a.target_id, a.detail, a.ip, " + "a.created_at, u.email as actor_email, u.name as actor_name, " + ACTOR_ORG_NAME + " as org_name" + base + where + " order by a.created_at desc, a.id desc limit ? offset ?", - (rs, rowNum) -> new AuditLogViewResponse(rs.getLong("id"), - rs.getObject("actor_id", Long.class), rs.getString("actor_email"), + (rs, rowNum) -> new AuditLogViewResponse(String.valueOf(rs.getLong("id")), + rs.getObject("actor_public_id", UUID.class), rs.getString("actor_email"), rs.getString("actor_name"), rs.getString("actor_role"), rs.getString("action"), rs.getString("target_type"), - targetIdOf(rs.getObject("target_id", Long.class)), + rs.getString("target_id"), detailOf(rs.getString("detail")), rs.getString("ip"), rs.getString("org_name"), rs.getObject("created_at", OffsetDateTime.class).toInstant()), @@ -172,24 +183,27 @@ private static PageResponse pageOf(List content, int page, int size, l return new PageResponse<>(content, page, size, total, totalPages); } - private static String targetIdOf(Long targetId) { - return targetId != null ? String.valueOf(targetId) : null; - } - private JsonNode detailOf(String detailJson) { return detailJson != null ? objectMapper.readTree(detailJson) : null; } /** Org tier pinned to their org; another org's id answers 404. */ - private static Long scopeOrgId(AuthenticatedUser actor, Long orgId) { + private Long scopeOrgId(AuthenticatedUser actor, UUID orgId) { + Long requested = orgId == null ? null : jdbcTemplate.query( + "select id from orgs where public_id = ?", + rs -> rs.next() ? rs.getLong(1) : null, orgId); if (!actor.role().isOrgTier()) { - return orgId; + // An id no org has filters to nothing, as a non-matching number did. + if (orgId != null && requested == null) { + return NO_SUCH_ORG; + } + return requested; } if (actor.orgId() == null) { throw new ApiException(HttpStatus.FORBIDDEN, ErrorCodes.ACCESS_DENIED, "접근 권한이 없습니다", "관리 기관이 지정되지 않은 계정입니다."); } - if (orgId != null && !orgId.equals(actor.orgId())) { + if (orgId != null && !actor.orgId().equals(requested)) { throw new ApiException(HttpStatus.NOT_FOUND, ErrorCodes.RESOURCE_NOT_FOUND, "리소스를 찾을 수 없습니다", "해당 기관을 찾을 수 없습니다."); } diff --git a/src/main/java/kr/ac/pusan/pickle/audit/AuditService.java b/src/main/java/kr/ac/pusan/pickle/audit/AuditService.java index 05a1e8a0..5ed1a8ba 100644 --- a/src/main/java/kr/ac/pusan/pickle/audit/AuditService.java +++ b/src/main/java/kr/ac/pusan/pickle/audit/AuditService.java @@ -1,6 +1,7 @@ package kr.ac.pusan.pickle.audit; import java.util.Map; +import java.util.UUID; import org.springframework.beans.factory.ObjectProvider; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; @@ -162,14 +163,22 @@ public AuditService(JdbcTemplate jdbcTemplate, ObjectMapper objectMapper, this.self = self; } + /** + * {@code targetId} is the target row's public identifier: the column + * is text and holds a UUID string. Targets that have no public identity + * (a refresh token, a settings key) pass null and put whatever identifies + * them in {@code detail}. Rows written before V78 hold the internal number + * of the day as text and mean what they always meant. + */ @Transactional(propagation = Propagation.REQUIRES_NEW) - public void record(Long actorId, String actorRole, String action, String targetType, Long targetId, + public void record(Long actorId, String actorRole, String action, String targetType, UUID targetId, Map detail, String ip) { String detailJson = (detail == null || detail.isEmpty()) ? null : objectMapper.writeValueAsString(detail); jdbcTemplate.update(""" insert into audit_logs (actor_id, actor_role, action, target_type, target_id, detail, ip) values (?, ?, ?, ?, ?, ?::jsonb, ?) - """, actorId, actorRole, action, targetType, targetId, detailJson, ip); + """, actorId, actorRole, action, targetType, + targetId == null ? null : targetId.toString(), detailJson, ip); } /** @@ -186,7 +195,7 @@ insert into audit_logs (actor_id, actor_role, action, target_type, target_id, de * therefore keep calling {@link #record} directly.

*/ public void recordAfterCommit(Long actorId, String actorRole, String action, String targetType, - Long targetId, Map detail, String ip) { + UUID targetId, Map detail, String ip) { if (!TransactionSynchronizationManager.isSynchronizationActive()) { record(actorId, actorRole, action, targetType, targetId, detail, ip); return; diff --git a/src/main/java/kr/ac/pusan/pickle/audit/dto/ActivityEntryResponse.java b/src/main/java/kr/ac/pusan/pickle/audit/dto/ActivityEntryResponse.java index 79dddb35..79f888f5 100644 --- a/src/main/java/kr/ac/pusan/pickle/audit/dto/ActivityEntryResponse.java +++ b/src/main/java/kr/ac/pusan/pickle/audit/dto/ActivityEntryResponse.java @@ -1,12 +1,14 @@ package kr.ac.pusan.pickle.audit.dto; +import io.swagger.v3.oas.annotations.media.Schema; import java.time.Instant; import org.jspecify.annotations.Nullable; import tools.jackson.databind.JsonNode; /** Contract {@code ActivityEntry}: one self-view audit row (login history included). */ public record ActivityEntryResponse( - long id, + @Schema(description = "활동 행 식별자. 목록 렌더링용 키이며 어떤 조회 파라미터도 아닙니다.") + String id, String action, @Nullable String targetType, @Nullable String targetId, diff --git a/src/main/java/kr/ac/pusan/pickle/audit/dto/AuditLogViewResponse.java b/src/main/java/kr/ac/pusan/pickle/audit/dto/AuditLogViewResponse.java index 2e200681..7e45eb80 100644 --- a/src/main/java/kr/ac/pusan/pickle/audit/dto/AuditLogViewResponse.java +++ b/src/main/java/kr/ac/pusan/pickle/audit/dto/AuditLogViewResponse.java @@ -1,6 +1,8 @@ package kr.ac.pusan.pickle.audit.dto; +import io.swagger.v3.oas.annotations.media.Schema; import java.time.Instant; +import java.util.UUID; import org.jspecify.annotations.Nullable; import tools.jackson.databind.JsonNode; @@ -11,8 +13,9 @@ * internal actors (e.g. the SSH gateway) stamp roles outside the user enum. */ public record AuditLogViewResponse( - long id, - @Nullable Long actorId, + @Schema(description = "감사 로그 행 식별자. 목록 렌더링용 키이며 어떤 조회 파라미터도 아닙니다.") + String id, + @Nullable UUID actorId, @Nullable String actorEmail, @Nullable String actorName, @Nullable String actorRole, diff --git a/src/main/java/kr/ac/pusan/pickle/auth/AuthService.java b/src/main/java/kr/ac/pusan/pickle/auth/AuthService.java index c06befb0..64b77236 100644 --- a/src/main/java/kr/ac/pusan/pickle/auth/AuthService.java +++ b/src/main/java/kr/ac/pusan/pickle/auth/AuthService.java @@ -167,7 +167,7 @@ private void createAccount(SignupRequest request, String email, String ip) { termsService.recordSignupConsents(user.getId(), request.consents()); sendVerificationMail(user); auditService.record(user.getId(), user.getRole().name(), AuditService.AUTH_SIGNUP, - "user", user.getId(), Map.of("email", user.getEmail()), ip); + "user", user.getPublicId(), Map.of("email", user.getEmail()), ip); } private static MessageResponse signupAccepted() { @@ -199,7 +199,7 @@ public MessageResponse verifyEmail(String rawToken, String ip) { } personalWorkspaceService.ensurePersonalWorkspace(user); auditService.record(user.getId(), user.getRole().name(), AuditService.AUTH_VERIFY, - "user", user.getId(), Map.of("email", user.getEmail()), ip); + "user", user.getPublicId(), Map.of("email", user.getEmail()), ip); return new MessageResponse("이메일 인증이 완료되었습니다. 이제 로그인할 수 있습니다."); } @@ -231,7 +231,7 @@ public LoginOutcome login(LoginRequest request, String ip, String userAgent) { rateLimitService.registerLoginFailure(email, ip); auditService.record(found.map(User::getId).orElse(null), found.map(u -> u.getRole().name()).orElse(null), AuditService.AUTH_LOGIN_FAILED, - "user", found.map(User::getId).orElse(null), + "user", found.map(User::getPublicId).orElse(null), Map.of("email", email, "reason", "bad_credentials"), ip); throw invalidCredentials(); } @@ -239,14 +239,14 @@ public LoginOutcome login(LoginRequest request, String ip, String userAgent) { User user = found.get(); if (user.getStatus() == UserStatus.PENDING_VERIFICATION) { auditService.record(user.getId(), user.getRole().name(), AuditService.AUTH_LOGIN_FAILED, - "user", user.getId(), Map.of("email", email, "reason", "email_not_verified"), ip); + "user", user.getPublicId(), Map.of("email", email, "reason", "email_not_verified"), ip); throw new ApiException(HttpStatus.FORBIDDEN, ErrorCodes.AUTH_EMAIL_NOT_VERIFIED, "이메일 인증이 필요합니다", "가입 시 발송된 인증 메일을 확인한 뒤 다시 로그인해 주세요."); } if (user.getStatus() != UserStatus.ACTIVE) { // Uniform 401: do not disclose DISABLED/WITHDRAWN state. auditService.record(user.getId(), user.getRole().name(), AuditService.AUTH_LOGIN_FAILED, - "user", user.getId(), + "user", user.getPublicId(), Map.of("email", email, "reason", "status_" + user.getStatus().name().toLowerCase(Locale.ROOT)), ip); throw invalidCredentials(); @@ -262,7 +262,7 @@ public LoginOutcome login(LoginRequest request, String ip, String userAgent) { rateLimitService.clearLoginFailures(email, ip); var issued = refreshTokenService.issue(user.getId(), authProperties.refreshTokenTtl(), null, userAgent, ip); auditService.record(user.getId(), user.getRole().name(), AuditService.AUTH_LOGIN, - "user", user.getId(), Map.of("email", email), ip); + "user", user.getPublicId(), Map.of("email", email), ip); return new AuthResult( new AuthTokenResponse(jwtService.createAccessToken(user), UserSummaryResponse.from(user)), issued.rawToken()); @@ -287,7 +287,7 @@ public AuthResult completeMfaLogin(String mfaToken, String code, String recovery if (!mfaService.verifyEnrolledCode(user.getId(), code, recoveryCode)) { rateLimitService.registerLoginFailure(user.getEmail(), ip); auditService.record(user.getId(), user.getRole().name(), AuditService.AUTH_LOGIN_FAILED, - "user", user.getId(), Map.of("email", user.getEmail(), "reason", "mfa_code"), ip); + "user", user.getPublicId(), Map.of("email", user.getEmail(), "reason", "mfa_code"), ip); throw MfaService.loginCodeInvalid(); } mfaService.consumeChallenge(challenge); @@ -295,7 +295,7 @@ public AuthResult completeMfaLogin(String mfaToken, String code, String recovery var issued = refreshTokenService.issue(user.getId(), authProperties.refreshTokenTtl(), null, userAgent, ip); auditService.record(user.getId(), user.getRole().name(), AuditService.AUTH_LOGIN, - "user", user.getId(), Map.of("email", user.getEmail(), "stage", "mfa"), ip); + "user", user.getPublicId(), Map.of("email", user.getEmail(), "stage", "mfa"), ip); return new AuthResult( new AuthTokenResponse(jwtService.createAccessToken(user), UserSummaryResponse.from(user)), issued.rawToken()); @@ -316,7 +316,7 @@ public AuthResult refresh(String rawToken, String ip, String userAgent) { // Theft signal: reuse of a rotated token revokes the whole chain. refreshTokenService.revokeChainFrom(current.getId()); auditService.record(current.getUserId(), null, AuditService.AUTH_REFRESH_REUSE_DETECTED, - "refresh_token", current.getId(), Map.of(), ip); + "refresh_token", null, Map.of("refreshTokenId", current.getId()), ip); throw refreshTokenInvalid(); } if (current.isExpired(Instant.now())) { @@ -337,7 +337,7 @@ public AuthResult refresh(String rawToken, String ip, String userAgent) { // Lost a race with another rotation of the same token: reuse. refreshTokenService.revokeChainFrom(current.getId()); auditService.record(current.getUserId(), null, AuditService.AUTH_REFRESH_REUSE_DETECTED, - "refresh_token", current.getId(), Map.of(), ip); + "refresh_token", null, Map.of("refreshTokenId", current.getId()), ip); return refreshTokenInvalid(); }); return new AuthResult( @@ -353,7 +353,7 @@ public void logout(String rawToken, String ip) { refreshTokenService.findByRawToken(rawToken).ifPresent(token -> { refreshTokenService.revoke(token.getId()); auditService.record(token.getUserId(), null, AuditService.AUTH_LOGOUT, - "refresh_token", token.getId(), Map.of(), ip); + "refresh_token", null, Map.of("refreshTokenId", token.getId()), ip); }); } @@ -378,7 +378,7 @@ public AuthResult changePassword(long userId, String currentPassword, String new // hijacked session cannot switch endpoints to keep guessing. rateLimitService.registerLoginFailure(user.getEmail(), ip); auditService.record(user.getId(), user.getRole().name(), AuditService.ACCOUNT_PASSWORD_CHANGE, - "user", user.getId(), Map.of("result", "mismatch"), ip); + "user", user.getPublicId(), Map.of("result", "mismatch"), ip); throw passwordMismatch(); } rateLimitService.clearLoginFailures(user.getEmail(), ip); @@ -392,7 +392,7 @@ public AuthResult changePassword(long userId, String currentPassword, String new var issued = refreshTokenService.issue(user.getId(), authProperties.refreshTokenTtl(), null, userAgent, ip); auditService.recordAfterCommit(user.getId(), user.getRole().name(), - AuditService.ACCOUNT_PASSWORD_CHANGE, "user", user.getId(), Map.of(), ip); + AuditService.ACCOUNT_PASSWORD_CHANGE, "user", user.getPublicId(), Map.of(), ip); notificationService.publish(user.getId(), NotificationEvent.ACCOUNT_PASSWORD_CHANGED, Map.of(), "account_password_changed:" + user.getId() + ":" + user.getTokenVersion()); return new AuthResult( @@ -451,7 +451,7 @@ public MessageResponse confirmPasswordReset(String rawToken, String newPassword, rateLimitService.clearLoginFailures(user.getEmail(), ip); auditService.recordAfterCommit(user.getId(), user.getRole().name(), - AuditService.ACCOUNT_PASSWORD_RESET, "user", user.getId(), Map.of(), ip); + AuditService.ACCOUNT_PASSWORD_RESET, "user", user.getPublicId(), Map.of(), ip); notificationService.publish(user.getId(), NotificationEvent.ACCOUNT_PASSWORD_CHANGED, Map.of(), "account_password_reset:" + user.getId() + ":" + user.getTokenVersion()); return new MessageResponse("비밀번호가 변경되었습니다. 새 비밀번호로 로그인해 주세요."); diff --git a/src/main/java/kr/ac/pusan/pickle/auth/ReauthService.java b/src/main/java/kr/ac/pusan/pickle/auth/ReauthService.java index a9de89aa..9e1396b4 100644 --- a/src/main/java/kr/ac/pusan/pickle/auth/ReauthService.java +++ b/src/main/java/kr/ac/pusan/pickle/auth/ReauthService.java @@ -56,7 +56,7 @@ public ReverifyResponse issue(AuthenticatedUser actor, String password, String i if (!passwordEncoder.matches(password, user.getPasswordHash())) { rateLimitService.registerLoginFailure(user.getEmail(), ip); auditService.record(actor.id(), actor.role().name(), AuditService.AUTH_REVERIFY, - "user", user.getId(), Map.of("result", "mismatch"), ip); + "user", user.getPublicId(), Map.of("result", "mismatch"), ip); throw passwordMismatch(); } rateLimitService.clearLoginFailures(user.getEmail(), ip); @@ -65,7 +65,7 @@ public ReverifyResponse issue(AuthenticatedUser actor, String password, String i repository.save(new AuthReverification(user.getId(), TokenHasher.sha256Hex(rawToken), user.getTokenVersion(), expiresAt, ip)); auditService.record(actor.id(), actor.role().name(), AuditService.AUTH_REVERIFY, - "user", user.getId(), Map.of("result", "success"), ip); + "user", user.getPublicId(), Map.of("result", "success"), ip); return new ReverifyResponse(rawToken, expiresAt); } diff --git a/src/main/java/kr/ac/pusan/pickle/auth/dto/UserSummaryResponse.java b/src/main/java/kr/ac/pusan/pickle/auth/dto/UserSummaryResponse.java index 158b5eae..065ee4b7 100644 --- a/src/main/java/kr/ac/pusan/pickle/auth/dto/UserSummaryResponse.java +++ b/src/main/java/kr/ac/pusan/pickle/auth/dto/UserSummaryResponse.java @@ -1,12 +1,14 @@ package kr.ac.pusan.pickle.auth.dto; +import java.util.UUID; import kr.ac.pusan.pickle.user.User; import kr.ac.pusan.pickle.user.UserRole; /** Contract schema {@code UserSummary}. */ -public record UserSummaryResponse(Long id, String email, String name, UserRole role) { +public record UserSummaryResponse(UUID id, String email, String name, UserRole role) { public static UserSummaryResponse from(User user) { - return new UserSummaryResponse(user.getId(), user.getEmail(), user.getName(), user.getRole()); + return new UserSummaryResponse(user.getPublicId(), user.getEmail(), user.getName(), + user.getRole()); } } diff --git a/src/main/java/kr/ac/pusan/pickle/campusip/AdminCampusIpRequestController.java b/src/main/java/kr/ac/pusan/pickle/campusip/AdminCampusIpRequestController.java index a421e722..c556e5ea 100644 --- a/src/main/java/kr/ac/pusan/pickle/campusip/AdminCampusIpRequestController.java +++ b/src/main/java/kr/ac/pusan/pickle/campusip/AdminCampusIpRequestController.java @@ -6,6 +6,7 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; +import java.util.UUID; import kr.ac.pusan.pickle.campusip.dto.AdminCampusIpRequestView; import kr.ac.pusan.pickle.campusip.dto.UpdateCampusIpRequestStatusRequest; import kr.ac.pusan.pickle.common.web.PageResponse; @@ -39,7 +40,7 @@ public AdminCampusIpRequestController(CampusIpRequestService campusIpRequestServ @GetMapping public PageResponse listAdminCampusIpRequests( @RequestParam(required = false) CampusIpRequestStatus status, - @RequestParam(required = false) Long vmId, + @RequestParam(required = false) UUID vmId, @RequestParam(defaultValue = "0") @Min(0) int page, @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) { return campusIpRequestService.adminList(status, vmId, page, size); @@ -48,7 +49,7 @@ public PageResponse listAdminCampusIpRequests( @PostMapping("/{requestId}/status") @PreAuthorize("hasRole('SYS_ADMIN')") public AdminCampusIpRequestView updateAdminCampusIpRequestStatus( - @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable long requestId, + @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable UUID requestId, @Valid @RequestBody UpdateCampusIpRequestStatusRequest request, HttpServletRequest httpRequest) { return campusIpRequestService.updateStatus(principal, requestId, request, diff --git a/src/main/java/kr/ac/pusan/pickle/campusip/CampusIpRequest.java b/src/main/java/kr/ac/pusan/pickle/campusip/CampusIpRequest.java index 13d5851e..e232c607 100644 --- a/src/main/java/kr/ac/pusan/pickle/campusip/CampusIpRequest.java +++ b/src/main/java/kr/ac/pusan/pickle/campusip/CampusIpRequest.java @@ -9,6 +9,7 @@ import jakarta.persistence.Id; import jakarta.persistence.Table; import java.time.Instant; +import java.util.UUID; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.annotations.UpdateTimestamp; @@ -28,6 +29,14 @@ public class CampusIpRequest { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + /** + * The identifier this row wears outside the API boundary. Internal joins, + * sorts and foreign keys keep using {@link #id}. + */ + @JdbcTypeCode(SqlTypes.UUID) + @Column(name = "public_id", nullable = false, updatable = false, unique = true) + private UUID publicId = UUID.randomUUID(); + @Column(name = "vm_id", nullable = false) private Long vmId; @@ -81,6 +90,10 @@ public Long getId() { return id; } + public UUID getPublicId() { + return publicId; + } + public Long getVmId() { return vmId; } diff --git a/src/main/java/kr/ac/pusan/pickle/campusip/CampusIpRequestController.java b/src/main/java/kr/ac/pusan/pickle/campusip/CampusIpRequestController.java index 36b2efb4..15ddb358 100644 --- a/src/main/java/kr/ac/pusan/pickle/campusip/CampusIpRequestController.java +++ b/src/main/java/kr/ac/pusan/pickle/campusip/CampusIpRequestController.java @@ -5,6 +5,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import java.util.List; +import java.util.UUID; import kr.ac.pusan.pickle.campusip.dto.CampusIpRequestView; import kr.ac.pusan.pickle.campusip.dto.CreateCampusIpRequest; import kr.ac.pusan.pickle.security.AuthenticatedUser; @@ -37,14 +38,14 @@ public CampusIpRequestController(CampusIpRequestService campusIpRequestService) @GetMapping public List listVmCampusIpRequests( - @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable long vmId) { + @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable UUID vmId) { return campusIpRequestService.list(principal, vmId); } @PostMapping @ResponseStatus(HttpStatus.CREATED) public CampusIpRequestView requestVmCampusIp( - @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable long vmId, + @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable UUID vmId, @Valid @RequestBody CreateCampusIpRequest request, HttpServletRequest httpRequest) { return campusIpRequestService.create(principal, vmId, request, clientIp(httpRequest)); } @@ -53,8 +54,8 @@ public CampusIpRequestView requestVmCampusIp( @DeleteMapping("/{requestId}") @ResponseStatus(HttpStatus.NO_CONTENT) public void cancelVmCampusIpRequest( - @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable long vmId, - @PathVariable long requestId, HttpServletRequest httpRequest) { + @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable UUID vmId, + @PathVariable UUID requestId, HttpServletRequest httpRequest) { campusIpRequestService.cancel(principal, vmId, requestId, clientIp(httpRequest)); } } diff --git a/src/main/java/kr/ac/pusan/pickle/campusip/CampusIpRequestRepository.java b/src/main/java/kr/ac/pusan/pickle/campusip/CampusIpRequestRepository.java index 10f2a93b..37cce6ff 100644 --- a/src/main/java/kr/ac/pusan/pickle/campusip/CampusIpRequestRepository.java +++ b/src/main/java/kr/ac/pusan/pickle/campusip/CampusIpRequestRepository.java @@ -3,12 +3,16 @@ import java.util.Collection; import java.util.List; import java.util.Optional; +import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; public interface CampusIpRequestRepository extends JpaRepository, JpaSpecificationExecutor { + /** Resolution of the identifier this row wears outside the API boundary. */ + Optional findByPublicId(UUID publicId); + List findByVmIdOrderByIdDesc(long vmId); Optional findByIdAndVmId(long id, long vmId); diff --git a/src/main/java/kr/ac/pusan/pickle/campusip/CampusIpRequestService.java b/src/main/java/kr/ac/pusan/pickle/campusip/CampusIpRequestService.java index 15c94c29..a7b68d9a 100644 --- a/src/main/java/kr/ac/pusan/pickle/campusip/CampusIpRequestService.java +++ b/src/main/java/kr/ac/pusan/pickle/campusip/CampusIpRequestService.java @@ -6,6 +6,7 @@ import java.util.Map; import java.util.Set; import java.util.TreeSet; +import java.util.UUID; import java.util.regex.Pattern; import kr.ac.pusan.pickle.access.ResourceRole; import kr.ac.pusan.pickle.access.VmAccessService; @@ -65,18 +66,21 @@ public class CampusIpRequestService { private final VmRepository vmRepository; private final VmAccessService vmAccessService; private final UserRepository userRepository; + private final kr.ac.pusan.pickle.orgs.OrgRepository orgRepository; private final NotificationService notificationService; private final AuditService auditService; private final ObjectMapper objectMapper; public CampusIpRequestService(CampusIpRequestRepository requestRepository, VmRepository vmRepository, VmAccessService vmAccessService, - UserRepository userRepository, NotificationService notificationService, + UserRepository userRepository, kr.ac.pusan.pickle.orgs.OrgRepository orgRepository, + NotificationService notificationService, AuditService auditService, ObjectMapper objectMapper) { this.requestRepository = requestRepository; this.vmRepository = vmRepository; this.vmAccessService = vmAccessService; this.userRepository = userRepository; + this.orgRepository = orgRepository; this.notificationService = notificationService; this.auditService = auditService; this.objectMapper = objectMapper; @@ -86,16 +90,18 @@ public CampusIpRequestService(CampusIpRequestRepository requestRepository, /** Reads need membership only (VIEWER+); writes need OWNER/EDITOR. */ @Transactional(readOnly = true) - public List list(AuthenticatedUser actor, long vmId) { - requireVmMember(actor, vmId); - return requestRepository.findByVmIdOrderByIdDesc(vmId).stream() - .map(this::toView).toList(); + public List list(AuthenticatedUser actor, UUID publicVmId) { + Vm vm = requireVmMember(actor, publicVmId); + List requests = requestRepository.findByVmIdOrderByIdDesc(vm.getId()); + Map userIds = userPublicIds(requests); + return requests.stream().map(request -> toView(request, vm.getPublicId(), userIds)).toList(); } @Transactional - public CampusIpRequestView create(AuthenticatedUser actor, long vmId, + public CampusIpRequestView create(AuthenticatedUser actor, UUID publicVmId, CreateCampusIpRequest request, String ip) { - Vm vm = requireVmOwnerOrEditor(actor, vmId); + Vm vm = requireVmOwnerOrEditor(actor, publicVmId); + long vmId = vm.getId(); String purpose = request.purpose().strip(); List ports = normalizePorts(request.ports()); if (requestRepository.existsByVmIdAndStatusIn(vmId, LIVE_STATUSES)) { @@ -112,13 +118,13 @@ public CampusIpRequestView create(AuthenticatedUser actor, long vmId, } notificationService.publish(notificationService.sysAdminIds(), NotificationEvent.CAMPUS_IP_REQUESTED, - Map.of("requestId", created.getId(), "vmId", vmId, "vmName", vm.getName(), + Map.of("requestId", created.getPublicId(), "vmId", vm.getPublicId(), "vmName", vm.getName(), "purpose", purpose), null); auditService.recordAfterCommit(actor.id(), actor.role().name(), - AuditService.CAMPUS_IP_REQUEST, "campus_ip_request", created.getId(), + AuditService.CAMPUS_IP_REQUEST, "campus_ip_request", created.getPublicId(), Map.of("vmId", vmId, "ports", ports), ip); - return toView(created); + return toView(created, vm.getPublicId(), Map.of(actor.id(), actor.publicId())); } /** @@ -126,16 +132,18 @@ public CampusIpRequestView create(AuthenticatedUser actor, long vmId, * only a still-unreviewed (REQUESTED) 신청. */ @Transactional - public void cancel(AuthenticatedUser actor, long vmId, long requestId, String ip) { - requireVmOwnerOrEditor(actor, vmId); - CampusIpRequest request = requestRepository.findByIdAndVmId(requestId, vmId) + public void cancel(AuthenticatedUser actor, UUID publicVmId, UUID publicRequestId, String ip) { + Vm vm = requireVmOwnerOrEditor(actor, publicVmId); + long vmId = vm.getId(); + CampusIpRequest request = requestRepository.findByPublicId(publicRequestId) + .filter(row -> row.getVmId() == vmId) .orElseThrow(CampusIpRequestService::requestNotFound); if (request.getStatus() != CampusIpRequestStatus.REQUESTED) { throw invalidTransition("검토가 시작되기 전(REQUESTED)의 신청만 취소할 수 있습니다."); } requestRepository.delete(request); auditService.recordAfterCommit(actor.id(), actor.role().name(), - AuditService.CAMPUS_IP_CANCEL, "campus_ip_request", requestId, + AuditService.CAMPUS_IP_CANCEL, "campus_ip_request", request.getPublicId(), Map.of("vmId", vmId), ip); } @@ -143,34 +151,39 @@ public void cancel(AuthenticatedUser actor, long vmId, long requestId, String ip @Transactional(readOnly = true) public PageResponse adminList(CampusIpRequestStatus status, - Long vmId, int page, int size) { + UUID publicVmId, int page, int size) { Specification spec = (root, query, cb) -> cb.conjunction(); if (status != null) { spec = spec.and((root, query, cb) -> cb.equal(root.get("status"), status)); } - if (vmId != null) { + if (publicVmId != null) { + // An id no VM has filters to nothing, as a non-matching number did. + Long vmId = vmRepository.findByPublicId(publicVmId).map(Vm::getId).orElse(-1L); spec = spec.and((root, query, cb) -> cb.equal(root.get("vmId"), vmId)); } Page result = requestRepository.findAll(spec, PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "id"))); + Map userIds = userPublicIds(result.getContent()); List views = result.getContent().stream().map(request -> { Vm vm = vmRepository.findById(request.getVmId()).orElse(null); User requester = userRepository.findById(request.getRequestedBy()).orElse(null); - return new AdminCampusIpRequestView(request.getId(), request.getVmId(), - vm != null ? vm.getName() : null, vm != null ? vm.getOrgId() : null, + return new AdminCampusIpRequestView(request.getPublicId(), + vm != null ? vm.getPublicId() : null, + vm != null ? vm.getName() : null, orgPublicId(vm), request.getPurpose(), parsePorts(request.getPorts()), request.getStatus(), request.getGrantedAddress(), request.getAdminNote(), - request.getRequestedBy(), + userIds.get(request.getRequestedBy()), requester != null ? requester.getEmail() : null, - request.getProcessedBy(), request.getProcessedAt(), request.getCreatedAt()); + userIds.get(request.getProcessedBy()), request.getProcessedAt(), + request.getCreatedAt()); }).toList(); return PageResponse.of(views, result); } @Transactional - public AdminCampusIpRequestView updateStatus(AuthenticatedUser actor, long requestId, + public AdminCampusIpRequestView updateStatus(AuthenticatedUser actor, UUID publicRequestId, UpdateCampusIpRequestStatusRequest body, String ip) { - CampusIpRequest request = requestRepository.findById(requestId) + CampusIpRequest request = requestRepository.findByPublicId(publicRequestId) .orElseThrow(CampusIpRequestService::requestNotFound); CampusIpRequestStatus from = request.getStatus(); CampusIpRequestStatus to = body.status(); @@ -202,14 +215,18 @@ public AdminCampusIpRequestView updateStatus(AuthenticatedUser actor, long reque NotificationEvent.CAMPUS_IP_STATUS_CHANGED, notificationArgs(request, vm), null); auditService.recordAfterCommit(actor.id(), actor.role().name(), - AuditService.CAMPUS_IP_STATUS_UPDATE, "campus_ip_request", requestId, + AuditService.CAMPUS_IP_STATUS_UPDATE, "campus_ip_request", request.getPublicId(), Map.of("vmId", request.getVmId(), "from", from.name(), "to", to.name()), ip); User requester = userRepository.findById(request.getRequestedBy()).orElse(null); - return new AdminCampusIpRequestView(request.getId(), request.getVmId(), - vm != null ? vm.getName() : null, vm != null ? vm.getOrgId() : null, + Map userIds = userPublicIds(List.of(request)); + return new AdminCampusIpRequestView(request.getPublicId(), + vm != null ? vm.getPublicId() : null, + vm != null ? vm.getName() : null, orgPublicId(vm), request.getPurpose(), parsePorts(request.getPorts()), request.getStatus(), - request.getGrantedAddress(), request.getAdminNote(), request.getRequestedBy(), - requester != null ? requester.getEmail() : null, request.getProcessedBy(), + request.getGrantedAddress(), request.getAdminNote(), + userIds.get(request.getRequestedBy()), + requester != null ? requester.getEmail() : null, + userIds.get(request.getProcessedBy()), request.getProcessedAt(), request.getCreatedAt()); } @@ -227,8 +244,11 @@ static boolean isLegalTransition(CampusIpRequestStatus from, CampusIpRequestStat private Map notificationArgs(CampusIpRequest request, Vm vm) { Map args = new java.util.LinkedHashMap<>(); - args.put("requestId", request.getId()); - args.put("vmId", request.getVmId()); + // Public ids: these travel into the notification's link path, which the + // console resolves. The sibling publish at the create path already does + // this -- the two disagreed until 2026-08-11. + args.put("requestId", request.getPublicId()); + args.put("vmId", vm != null ? vm.getPublicId() : null); args.put("vmName", vm != null ? vm.getName() : ""); args.put("statusLabel", statusLabel(request.getStatus())); if (request.getGrantedAddress() != null @@ -275,11 +295,28 @@ private static List normalizePorts(List ports) { return List.copyOf(normalized); } - private CampusIpRequestView toView(CampusIpRequest request) { - return new CampusIpRequestView(request.getId(), request.getVmId(), request.getPurpose(), + private CampusIpRequestView toView(CampusIpRequest request, UUID vmId, Map userIds) { + return new CampusIpRequestView(request.getPublicId(), vmId, request.getPurpose(), parsePorts(request.getPorts()), request.getStatus(), request.getGrantedAddress(), - request.getAdminNote(), request.getRequestedBy(), request.getProcessedAt(), - request.getCreatedAt()); + request.getAdminNote(), userIds.get(request.getRequestedBy()), + request.getProcessedAt(), request.getCreatedAt()); + } + + /** Batch account join: both {@code requestedBy} and {@code processedBy} are public ids. */ + private Map userPublicIds(List requests) { + List ids = java.util.stream.Stream.concat( + requests.stream().map(CampusIpRequest::getRequestedBy), + requests.stream().map(CampusIpRequest::getProcessedBy)) + .filter(java.util.Objects::nonNull).distinct().toList(); + return ids.isEmpty() ? Map.of() + : userRepository.findAllById(ids).stream() + .collect(java.util.stream.Collectors.toMap(User::getId, User::getPublicId)); + } + + private UUID orgPublicId(Vm vm) { + return vm == null || vm.getOrgId() == null ? null + : orgRepository.findById(vm.getOrgId()).map(kr.ac.pusan.pickle.orgs.Org::getPublicId) + .orElse(null); } private List parsePorts(String json) { @@ -292,11 +329,11 @@ private List parsePorts(String json) { } /** Membership check (VIEWER+): non-members get the 404 existence mask. */ - private Vm requireVmMember(AuthenticatedUser actor, long vmId) { + private Vm requireVmMember(AuthenticatedUser actor, UUID vmId) { return vmAccessService.of(actor, vmId).requireVisible(); } - private Vm requireVmOwnerOrEditor(AuthenticatedUser actor, long vmId) { + private Vm requireVmOwnerOrEditor(AuthenticatedUser actor, UUID vmId) { return vmAccessService.of(actor, vmId).requireAtLeast(ResourceRole.EDITOR, "교내 IP를 신청할 권한이 없습니다", "이 VM의 소유자 또는 편집자만 교내 IP를 신청할 수 있습니다."); diff --git a/src/main/java/kr/ac/pusan/pickle/campusip/dto/AdminCampusIpRequestView.java b/src/main/java/kr/ac/pusan/pickle/campusip/dto/AdminCampusIpRequestView.java index 4aa9fb84..3d8c00df 100644 --- a/src/main/java/kr/ac/pusan/pickle/campusip/dto/AdminCampusIpRequestView.java +++ b/src/main/java/kr/ac/pusan/pickle/campusip/dto/AdminCampusIpRequestView.java @@ -3,15 +3,16 @@ import io.swagger.v3.oas.annotations.media.Schema; import java.time.Instant; import java.util.List; +import java.util.UUID; import kr.ac.pusan.pickle.campusip.CampusIpRequestStatus; import org.jspecify.annotations.Nullable; /** Contract schema {@code AdminCampusIpRequestView} — 신청 + VM 컨텍스트. */ public record AdminCampusIpRequestView( - Long id, - Long vmId, + UUID id, + UUID vmId, @Nullable String vmName, - @Nullable Long orgId, + @Nullable UUID orgId, @Schema(description = "신청 목적") String purpose, @Schema(description = "사용할 포트 번호 목록") @@ -23,11 +24,11 @@ public record AdminCampusIpRequestView( @Schema(description = "연결된 교내 IP 주소 (10.0.0.0/8)") String grantedAddress, @Nullable String adminNote, - Long requestedBy, + UUID requestedBy, @Nullable @Schema(description = "신청자 이메일") String requesterEmail, - @Nullable Long processedBy, + @Nullable UUID processedBy, @Nullable Instant processedAt, Instant createdAt) { } diff --git a/src/main/java/kr/ac/pusan/pickle/campusip/dto/CampusIpRequestView.java b/src/main/java/kr/ac/pusan/pickle/campusip/dto/CampusIpRequestView.java index 30926f02..8f3fec83 100644 --- a/src/main/java/kr/ac/pusan/pickle/campusip/dto/CampusIpRequestView.java +++ b/src/main/java/kr/ac/pusan/pickle/campusip/dto/CampusIpRequestView.java @@ -3,13 +3,14 @@ import io.swagger.v3.oas.annotations.media.Schema; import java.time.Instant; import java.util.List; +import java.util.UUID; import kr.ac.pusan.pickle.campusip.CampusIpRequestStatus; import org.jspecify.annotations.Nullable; /** Contract schema {@code CampusIpRequestView} — one 교내 IP 신청. */ public record CampusIpRequestView( - Long id, - Long vmId, + UUID id, + UUID vmId, @Schema(description = "신청 목적") String purpose, @Schema(description = "사용할 포트 번호 목록 (중복 제거·오름차순 정규화)") @@ -23,7 +24,7 @@ public record CampusIpRequestView( @Nullable @Schema(description = "관리자 메모") String adminNote, - Long requestedBy, + UUID requestedBy, @Nullable Instant processedAt, Instant createdAt) { } 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 18405bc6..45d31f5f 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 @@ -53,7 +53,6 @@ public final class ErrorCodes { * 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"; public static final String VM_INVALID_STATE = "VM_INVALID_STATE"; diff --git a/src/main/java/kr/ac/pusan/pickle/common/openapi/OpenApiConfig.java b/src/main/java/kr/ac/pusan/pickle/common/openapi/OpenApiConfig.java index e1d2ab41..e5aec11a 100644 --- a/src/main/java/kr/ac/pusan/pickle/common/openapi/OpenApiConfig.java +++ b/src/main/java/kr/ac/pusan/pickle/common/openapi/OpenApiConfig.java @@ -41,7 +41,7 @@ public class OpenApiConfig { /** Contract version served in {@code info.version}; bump on any contract change. */ - public static final String CONTRACT_VERSION = "0.37.0"; + public static final String CONTRACT_VERSION = "0.38.0"; /** Name of the bearer-JWT security scheme in the published spec. */ private static final String BEARER_SCHEME = "bearerAuth"; @@ -70,8 +70,8 @@ public OpenAPI pickleOpenApi() { .type(SecurityScheme.Type.HTTP) .scheme("bearer") .bearerFormat("JWT") - .description("액세스 토큰 (JWT HS256, 15분 만료). 클레임: sub, role, org_id, " - + "token_version. 비밀번호 변경·계정 비활성화 시 token_version이 올라가 " + .description("액세스 토큰 (JWT HS256, 15분 만료). 클레임: sub(계정 공개 식별자 UUID), " + + "role, token_version. 비밀번호 변경·계정 비활성화 시 token_version이 올라가 " + "기존 토큰이 즉시 무효화됩니다. 리프레시 토큰은 보안 스킴이 아니라 " + "__Host-pickle_refresh httpOnly 쿠키로만 오갑니다."))) // Applies to every operation unless the operation overrides it diff --git a/src/main/java/kr/ac/pusan/pickle/inventory/Node.java b/src/main/java/kr/ac/pusan/pickle/inventory/Node.java index 58238900..cec2897e 100644 --- a/src/main/java/kr/ac/pusan/pickle/inventory/Node.java +++ b/src/main/java/kr/ac/pusan/pickle/inventory/Node.java @@ -9,6 +9,7 @@ import jakarta.persistence.Id; import jakarta.persistence.Table; import java.time.Instant; +import java.util.UUID; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.annotations.UpdateTimestamp; @@ -27,6 +28,14 @@ public class Node { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + /** + * The identifier this row wears outside the API boundary. Internal joins, + * sorts and foreign keys keep using {@link #id}. + */ + @JdbcTypeCode(SqlTypes.UUID) + @Column(name = "public_id", nullable = false, updatable = false, unique = true) + private UUID publicId = UUID.randomUUID(); + @Column(nullable = false, unique = true) private String name; @@ -78,6 +87,10 @@ public Long getId() { return id; } + public UUID getPublicId() { + return publicId; + } + public String getName() { return name; } diff --git a/src/main/java/kr/ac/pusan/pickle/inventory/NodeRepository.java b/src/main/java/kr/ac/pusan/pickle/inventory/NodeRepository.java index 1c412910..3d06996f 100644 --- a/src/main/java/kr/ac/pusan/pickle/inventory/NodeRepository.java +++ b/src/main/java/kr/ac/pusan/pickle/inventory/NodeRepository.java @@ -1,9 +1,14 @@ package kr.ac.pusan.pickle.inventory; import java.util.List; +import java.util.Optional; +import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; public interface NodeRepository extends JpaRepository { + /** Resolution of the identifier this row wears outside the API boundary. */ + Optional findByPublicId(UUID publicId); + List findByStatusOrderByIdAsc(NodeStatus status); } diff --git a/src/main/java/kr/ac/pusan/pickle/inventory/OsImage.java b/src/main/java/kr/ac/pusan/pickle/inventory/OsImage.java index 45b79567..28512fb0 100644 --- a/src/main/java/kr/ac/pusan/pickle/inventory/OsImage.java +++ b/src/main/java/kr/ac/pusan/pickle/inventory/OsImage.java @@ -9,6 +9,7 @@ import jakarta.persistence.Id; import jakarta.persistence.Table; import java.time.Instant; +import java.util.UUID; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.annotations.UpdateTimestamp; @@ -24,6 +25,14 @@ public class OsImage { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + /** + * The identifier this row wears outside the API boundary. Internal joins, + * sorts and foreign keys keep using {@link #id}. + */ + @JdbcTypeCode(SqlTypes.UUID) + @Column(name = "public_id", nullable = false, updatable = false, unique = true) + private UUID publicId = UUID.randomUUID(); + @Column(nullable = false) private String name; @@ -96,6 +105,10 @@ public Long getId() { return id; } + public UUID getPublicId() { + return publicId; + } + public String getName() { return name; } diff --git a/src/main/java/kr/ac/pusan/pickle/inventory/OsImageRepository.java b/src/main/java/kr/ac/pusan/pickle/inventory/OsImageRepository.java index c17ece7f..31853a7f 100644 --- a/src/main/java/kr/ac/pusan/pickle/inventory/OsImageRepository.java +++ b/src/main/java/kr/ac/pusan/pickle/inventory/OsImageRepository.java @@ -1,12 +1,17 @@ package kr.ac.pusan.pickle.inventory; import java.util.List; +import java.util.Optional; +import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; public interface OsImageRepository extends JpaRepository { + /** Resolution of the identifier this row wears outside the API boundary. */ + Optional findByPublicId(UUID publicId); + /** * Display order of the OS catalog: distribution alphabetically, then release * ascending, then id. diff --git a/src/main/java/kr/ac/pusan/pickle/inventory/VmFlavor.java b/src/main/java/kr/ac/pusan/pickle/inventory/VmFlavor.java index 2e293ab3..353ccaf5 100644 --- a/src/main/java/kr/ac/pusan/pickle/inventory/VmFlavor.java +++ b/src/main/java/kr/ac/pusan/pickle/inventory/VmFlavor.java @@ -9,6 +9,7 @@ import jakarta.persistence.Id; import jakarta.persistence.Table; import java.time.Instant; +import java.util.UUID; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.annotations.UpdateTimestamp; @@ -28,6 +29,14 @@ public class VmFlavor { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + /** + * The identifier this row wears outside the API boundary. Internal joins, + * sorts and foreign keys keep using {@link #id}. + */ + @JdbcTypeCode(SqlTypes.UUID) + @Column(name = "public_id", nullable = false, updatable = false, unique = true) + private UUID publicId = UUID.randomUUID(); + @Column(nullable = false) private String name; @@ -76,6 +85,10 @@ public Long getId() { return id; } + public UUID getPublicId() { + return publicId; + } + public String getName() { return name; } diff --git a/src/main/java/kr/ac/pusan/pickle/inventory/VmFlavorRepository.java b/src/main/java/kr/ac/pusan/pickle/inventory/VmFlavorRepository.java index 5e6995fa..68a360cc 100644 --- a/src/main/java/kr/ac/pusan/pickle/inventory/VmFlavorRepository.java +++ b/src/main/java/kr/ac/pusan/pickle/inventory/VmFlavorRepository.java @@ -1,11 +1,16 @@ package kr.ac.pusan.pickle.inventory; import java.util.List; +import java.util.Optional; +import java.util.UUID; import org.springframework.data.domain.Sort; import org.springframework.data.jpa.repository.JpaRepository; public interface VmFlavorRepository extends JpaRepository { + /** Resolution of the identifier this row wears outside the API boundary. */ + Optional findByPublicId(UUID publicId); + /** * Display order of the spec presets: smallest first, on the three numbers * that make a preset what it is, with the id as the last tie-break. diff --git a/src/main/java/kr/ac/pusan/pickle/inventory/dto/OsImageResponse.java b/src/main/java/kr/ac/pusan/pickle/inventory/dto/OsImageResponse.java index c4bf887e..945d65e8 100644 --- a/src/main/java/kr/ac/pusan/pickle/inventory/dto/OsImageResponse.java +++ b/src/main/java/kr/ac/pusan/pickle/inventory/dto/OsImageResponse.java @@ -1,12 +1,13 @@ package kr.ac.pusan.pickle.inventory.dto; import kr.ac.pusan.pickle.inventory.CatalogStatus; +import java.util.UUID; import kr.ac.pusan.pickle.inventory.OsImage; import org.jspecify.annotations.Nullable; /** Contract schema {@code OsImage} — OS catalog entry (v0.23.0 axis split). */ public record OsImageResponse( - Long id, + UUID id, String name, String displayName, String osFamily, @@ -18,7 +19,7 @@ public record OsImageResponse( @Nullable String notes) { public static OsImageResponse from(OsImage image) { - return new OsImageResponse(image.getId(), image.getName(), image.getDisplayName(), + return new OsImageResponse(image.getPublicId(), image.getName(), image.getDisplayName(), image.getOsFamily(), image.getOsVersion(), image.getSshUsername(), image.getVersion(), image.getMinDiskGb(), image.getStatus(), image.getNotes()); diff --git a/src/main/java/kr/ac/pusan/pickle/inventory/dto/VmFlavorResponse.java b/src/main/java/kr/ac/pusan/pickle/inventory/dto/VmFlavorResponse.java index 5c82bd29..50d7b9e9 100644 --- a/src/main/java/kr/ac/pusan/pickle/inventory/dto/VmFlavorResponse.java +++ b/src/main/java/kr/ac/pusan/pickle/inventory/dto/VmFlavorResponse.java @@ -1,12 +1,13 @@ package kr.ac.pusan.pickle.inventory.dto; import kr.ac.pusan.pickle.inventory.CatalogStatus; +import java.util.UUID; import kr.ac.pusan.pickle.inventory.VmFlavor; import org.jspecify.annotations.Nullable; /** Contract schema {@code VmFlavor} — spec preset (v0.23.0 axis split). */ public record VmFlavorResponse( - Long id, + UUID id, String name, String displayName, int vcpu, @@ -16,7 +17,7 @@ public record VmFlavorResponse( @Nullable String notes) { public static VmFlavorResponse from(VmFlavor flavor) { - return new VmFlavorResponse(flavor.getId(), flavor.getName(), flavor.getDisplayName(), + return new VmFlavorResponse(flavor.getPublicId(), flavor.getName(), flavor.getDisplayName(), flavor.getVcpu(), flavor.getMemoryMb(), flavor.getDiskGb(), flavor.getStatus(), flavor.getNotes()); } diff --git a/src/main/java/kr/ac/pusan/pickle/ipam/IpAllocation.java b/src/main/java/kr/ac/pusan/pickle/ipam/IpAllocation.java index dcf04d13..3a03556d 100644 --- a/src/main/java/kr/ac/pusan/pickle/ipam/IpAllocation.java +++ b/src/main/java/kr/ac/pusan/pickle/ipam/IpAllocation.java @@ -9,6 +9,7 @@ import jakarta.persistence.Id; import jakarta.persistence.Table; import java.time.Instant; +import java.util.UUID; import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.type.SqlTypes; @@ -25,6 +26,14 @@ public class IpAllocation { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + /** + * The identifier this row wears outside the API boundary. Internal joins, + * sorts and foreign keys keep using {@link #id}. + */ + @JdbcTypeCode(SqlTypes.UUID) + @Column(name = "public_id", nullable = false, updatable = false, unique = true) + private UUID publicId = UUID.randomUUID(); + @Column(name = "pool_id", nullable = false) private Long poolId; @@ -54,6 +63,10 @@ public Long getId() { return id; } + public UUID getPublicId() { + return publicId; + } + public Long getPoolId() { return poolId; } diff --git a/src/main/java/kr/ac/pusan/pickle/ipam/IpAllocationRepository.java b/src/main/java/kr/ac/pusan/pickle/ipam/IpAllocationRepository.java index eb07eb15..419503f2 100644 --- a/src/main/java/kr/ac/pusan/pickle/ipam/IpAllocationRepository.java +++ b/src/main/java/kr/ac/pusan/pickle/ipam/IpAllocationRepository.java @@ -1,12 +1,16 @@ package kr.ac.pusan.pickle.ipam; import java.util.Optional; +import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; public interface IpAllocationRepository extends JpaRepository, JpaSpecificationExecutor { + /** Resolution of the identifier this row wears outside the API boundary. */ + Optional findByPublicId(UUID publicId); + /** * The VM's live allocation. Crash guard for the pipeline's alloc step: * {@code IpamService.allocate} may have committed before the crash while diff --git a/src/main/java/kr/ac/pusan/pickle/ipam/IpPool.java b/src/main/java/kr/ac/pusan/pickle/ipam/IpPool.java index 1c3cc3a3..b5d65f39 100644 --- a/src/main/java/kr/ac/pusan/pickle/ipam/IpPool.java +++ b/src/main/java/kr/ac/pusan/pickle/ipam/IpPool.java @@ -7,6 +7,7 @@ import jakarta.persistence.Id; import jakarta.persistence.Table; import java.time.Instant; +import java.util.UUID; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.annotations.UpdateTimestamp; @@ -26,6 +27,14 @@ public class IpPool { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + /** + * The identifier this row wears outside the API boundary. Internal joins, + * sorts and foreign keys keep using {@link #id}. + */ + @JdbcTypeCode(SqlTypes.UUID) + @Column(name = "public_id", nullable = false, updatable = false, unique = true) + private UUID publicId = UUID.randomUUID(); + @Column(nullable = false, unique = true) private String name; @@ -62,6 +71,10 @@ public Long getId() { return id; } + public UUID getPublicId() { + return publicId; + } + public String getName() { return name; } diff --git a/src/main/java/kr/ac/pusan/pickle/ipam/IpPoolRepository.java b/src/main/java/kr/ac/pusan/pickle/ipam/IpPoolRepository.java index 34733b9a..a4f8fd87 100644 --- a/src/main/java/kr/ac/pusan/pickle/ipam/IpPoolRepository.java +++ b/src/main/java/kr/ac/pusan/pickle/ipam/IpPoolRepository.java @@ -1,9 +1,13 @@ package kr.ac.pusan.pickle.ipam; import java.util.Optional; +import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; public interface IpPoolRepository extends JpaRepository { + /** Resolution of the identifier this row wears outside the API boundary. */ + Optional findByPublicId(UUID publicId); + Optional findByName(String name); } diff --git a/src/main/java/kr/ac/pusan/pickle/mfa/MfaService.java b/src/main/java/kr/ac/pusan/pickle/mfa/MfaService.java index 09662a8f..c1b95466 100644 --- a/src/main/java/kr/ac/pusan/pickle/mfa/MfaService.java +++ b/src/main/java/kr/ac/pusan/pickle/mfa/MfaService.java @@ -137,7 +137,7 @@ public MfaRecoveryCodesResponse activate(long userId, String code, String ip) { List codes = replaceRecoveryCodes(userId); auditService.record(user.getId(), user.getRole().name(), AuditService.ACCOUNT_MFA_ENROLL, - "user", user.getId(), Map.of(), ip); + "user", user.getPublicId(), Map.of(), ip); notificationService.publish(user.getId(), NotificationEvent.ACCOUNT_MFA_ENROLLED, Map.of(), "account_mfa_enrolled:" + user.getId() + ":" + mfa.getEnabledAt()); return new MfaRecoveryCodesResponse(codes); @@ -166,7 +166,7 @@ public void disable(long userId, String password, String code, String recoveryCo recoveryCodeRepository.deleteByUserId(userId); auditService.record(user.getId(), user.getRole().name(), AuditService.ACCOUNT_MFA_DISABLE, - "user", user.getId(), Map.of(), ip); + "user", user.getPublicId(), Map.of(), ip); notificationService.publish(user.getId(), NotificationEvent.ACCOUNT_MFA_DISABLED, Map.of(), "account_mfa_disabled:" + user.getId() + ":" + Instant.now().toEpochMilli()); } @@ -310,7 +310,7 @@ public void adminReset(long actorId, String actorRole, User target, String ip) { recoveryCodeRepository.deleteByUserId(target.getId()); auditService.record(actorId, actorRole, AuditService.ACCOUNT_MFA_RESET, - "user", target.getId(), Map.of("targetEmail", target.getEmail()), ip); + "user", target.getPublicId(), Map.of("targetEmail", target.getEmail()), ip); notificationService.publish(target.getId(), NotificationEvent.ACCOUNT_MFA_RESET, Map.of(), "account_mfa_reset:" + target.getId() + ":" + Instant.now().toEpochMilli()); } diff --git a/src/main/java/kr/ac/pusan/pickle/notification/Notification.java b/src/main/java/kr/ac/pusan/pickle/notification/Notification.java index 0405a012..475de717 100644 --- a/src/main/java/kr/ac/pusan/pickle/notification/Notification.java +++ b/src/main/java/kr/ac/pusan/pickle/notification/Notification.java @@ -9,6 +9,7 @@ import jakarta.persistence.Id; import jakarta.persistence.Table; import java.time.Instant; +import java.util.UUID; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.type.SqlTypes; @@ -27,6 +28,14 @@ public class Notification { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + /** + * The identifier this row wears outside the API boundary. Internal joins, + * sorts and foreign keys keep using {@link #id}. + */ + @JdbcTypeCode(SqlTypes.UUID) + @Column(name = "public_id", nullable = false, updatable = false, unique = true) + private UUID publicId = UUID.randomUUID(); + @Column(name = "user_id", nullable = false) private Long userId; @@ -92,6 +101,10 @@ public Long getId() { return id; } + public UUID getPublicId() { + return publicId; + } + public Long getUserId() { return userId; } diff --git a/src/main/java/kr/ac/pusan/pickle/notification/NotificationController.java b/src/main/java/kr/ac/pusan/pickle/notification/NotificationController.java index eb17f179..9a4ff951 100644 --- a/src/main/java/kr/ac/pusan/pickle/notification/NotificationController.java +++ b/src/main/java/kr/ac/pusan/pickle/notification/NotificationController.java @@ -2,6 +2,7 @@ import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; +import java.util.UUID; import kr.ac.pusan.pickle.common.web.PageResponse; import kr.ac.pusan.pickle.notification.dto.NotificationView; import kr.ac.pusan.pickle.notification.dto.ReadAllResponse; @@ -51,7 +52,7 @@ public UnreadCountResponse getUnreadNotificationCount( @PostMapping("/{notificationId}/read") public NotificationView markNotificationRead( @AuthenticationPrincipal AuthenticatedUser principal, - @PathVariable long notificationId) { + @PathVariable UUID notificationId) { return notificationService.markRead(principal.id(), notificationId); } diff --git a/src/main/java/kr/ac/pusan/pickle/notification/NotificationRepository.java b/src/main/java/kr/ac/pusan/pickle/notification/NotificationRepository.java index 4c34b80d..d606c5f0 100644 --- a/src/main/java/kr/ac/pusan/pickle/notification/NotificationRepository.java +++ b/src/main/java/kr/ac/pusan/pickle/notification/NotificationRepository.java @@ -1,12 +1,16 @@ package kr.ac.pusan.pickle.notification; import java.util.Optional; +import java.util.UUID; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; public interface NotificationRepository extends JpaRepository { + /** Resolution of the identifier this row wears outside the API boundary. */ + Optional findByPublicId(UUID publicId); + Page findByUserIdOrderByCreatedAtDescIdDesc(Long userId, Pageable pageable); Page findByUserIdAndReadAtIsNullOrderByCreatedAtDescIdDesc(Long userId, 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 a4be1b40..9b3e2b35 100644 --- a/src/main/java/kr/ac/pusan/pickle/notification/NotificationService.java +++ b/src/main/java/kr/ac/pusan/pickle/notification/NotificationService.java @@ -5,6 +5,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.UUID; import kr.ac.pusan.pickle.access.AccessGranteeType; import kr.ac.pusan.pickle.access.ResourceAccessGrant; import kr.ac.pusan.pickle.access.ResourceAccessGrantRepository; @@ -205,9 +206,10 @@ public List sysAdminIds() { * Other users' rows answer 404 (existence masked). */ @Transactional - public NotificationView markRead(long actorUserId, long notificationId) { + public NotificationView markRead(long actorUserId, UUID notificationId) { Notification notification = notificationRepository - .findByIdAndUserId(notificationId, actorUserId) + .findByPublicId(notificationId) + .filter(row -> row.getUserId() == actorUserId) .orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, ErrorCodes.RESOURCE_NOT_FOUND, "리소스를 찾을 수 없습니다", "해당 알림이 존재하지 않습니다.")); diff --git a/src/main/java/kr/ac/pusan/pickle/notification/dto/NotificationView.java b/src/main/java/kr/ac/pusan/pickle/notification/dto/NotificationView.java index 7d5b0fd6..9c4df74a 100644 --- a/src/main/java/kr/ac/pusan/pickle/notification/dto/NotificationView.java +++ b/src/main/java/kr/ac/pusan/pickle/notification/dto/NotificationView.java @@ -1,13 +1,14 @@ package kr.ac.pusan.pickle.notification.dto; import java.time.Instant; +import java.util.UUID; import kr.ac.pusan.pickle.notification.Notification; import kr.ac.pusan.pickle.notification.NotificationImportance; import org.jspecify.annotations.Nullable; /** Contract {@code NotificationView}: one in-app notification (own rows only). */ public record NotificationView( - long id, + UUID id, String event, String title, String body, @@ -17,7 +18,7 @@ public record NotificationView( @Nullable Instant readAt) { public static NotificationView from(Notification notification) { - return new NotificationView(notification.getId(), notification.getEvent(), + return new NotificationView(notification.getPublicId(), notification.getEvent(), notification.getTitle(), notification.getBody(), notification.getLinkPath(), notification.getImportance(), notification.getCreatedAt(), notification.getReadAt()); diff --git a/src/main/java/kr/ac/pusan/pickle/orgs/Org.java b/src/main/java/kr/ac/pusan/pickle/orgs/Org.java index 07b0bbf4..1f3c5cf4 100644 --- a/src/main/java/kr/ac/pusan/pickle/orgs/Org.java +++ b/src/main/java/kr/ac/pusan/pickle/orgs/Org.java @@ -9,6 +9,7 @@ import jakarta.persistence.Id; import jakarta.persistence.Table; import java.time.Instant; +import java.util.UUID; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.annotations.UpdateTimestamp; @@ -22,12 +23,17 @@ public class Org { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + /** + * The identifier this row wears outside the API boundary. Internal joins, + * sorts and foreign keys keep using {@link #id}. + */ + @JdbcTypeCode(SqlTypes.UUID) + @Column(name = "public_id", nullable = false, updatable = false, unique = true) + private UUID publicId = UUID.randomUUID(); + @Column(nullable = false) private String name; - @Column(nullable = false, unique = true) - private String slug; - private String description; @Enumerated(EnumType.STRING) @@ -54,9 +60,8 @@ public class Org { protected Org() { } - public Org(String name, String slug, String description) { + public Org(String name, String description) { this.name = name; - this.slug = slug; this.description = description; } @@ -64,6 +69,10 @@ public Long getId() { return id; } + public UUID getPublicId() { + return publicId; + } + public String getName() { return name; } @@ -72,10 +81,6 @@ public void setName(String name) { this.name = name; } - public String getSlug() { - return slug; - } - public String getDescription() { return description; } diff --git a/src/main/java/kr/ac/pusan/pickle/orgs/OrgRepository.java b/src/main/java/kr/ac/pusan/pickle/orgs/OrgRepository.java index 370dc465..3093b171 100644 --- a/src/main/java/kr/ac/pusan/pickle/orgs/OrgRepository.java +++ b/src/main/java/kr/ac/pusan/pickle/orgs/OrgRepository.java @@ -2,15 +2,21 @@ import java.util.List; import java.util.Optional; +import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; public interface OrgRepository extends JpaRepository { - Optional findBySlug(String slug); + /** Resolution of the identifier this row wears outside the API boundary. */ + Optional findByPublicId(UUID publicId); + + /** + * Lookup by display name, for the dev seeder's find-or-create. Names carry + * no uniqueness constraint, so the oldest match wins. + */ + Optional findFirstByNameOrderByIdAsc(String name); List findByStatusOrderByIdAsc(OrgStatus status); List findByStatusAndHiddenFalseOrderByIdAsc(OrgStatus status); - - boolean existsBySlug(String slug); } diff --git a/src/main/java/kr/ac/pusan/pickle/orgs/dto/OrgSummaryResponse.java b/src/main/java/kr/ac/pusan/pickle/orgs/dto/OrgSummaryResponse.java index 951345b0..7aced9e2 100644 --- a/src/main/java/kr/ac/pusan/pickle/orgs/dto/OrgSummaryResponse.java +++ b/src/main/java/kr/ac/pusan/pickle/orgs/dto/OrgSummaryResponse.java @@ -1,15 +1,16 @@ package kr.ac.pusan.pickle.orgs.dto; import kr.ac.pusan.pickle.orgs.Org; +import java.util.UUID; import kr.ac.pusan.pickle.orgs.OrgStatus; import org.jspecify.annotations.Nullable; /** Contract schema {@code OrgSummaryResponse} (v0.15.0 adds {@code hidden}). */ -public record OrgSummaryResponse(Long id, String name, String slug, @Nullable String description, +public record OrgSummaryResponse(UUID id, String name, @Nullable String description, OrgStatus status, boolean hidden) { public static OrgSummaryResponse from(Org org) { - return new OrgSummaryResponse(org.getId(), org.getName(), org.getSlug(), + return new OrgSummaryResponse(org.getPublicId(), org.getName(), org.getDescription(), org.getStatus(), org.isHidden()); } } diff --git a/src/main/java/kr/ac/pusan/pickle/provisioning/DeleteVmJob.java b/src/main/java/kr/ac/pusan/pickle/provisioning/DeleteVmJob.java index b36aea6b..37629253 100644 --- a/src/main/java/kr/ac/pusan/pickle/provisioning/DeleteVmJob.java +++ b/src/main/java/kr/ac/pusan/pickle/provisioning/DeleteVmJob.java @@ -429,7 +429,7 @@ private void notifyOrgAdmins(Vm vm) { .map(User::getId) .toList(); notificationService.publish(admins, NotificationEvent.VM_DELETE_COMPLETED, - Map.of("vmId", vm.getId(), "vmName", vm.getName()), + Map.of("vmId", vm.getPublicId(), "vmName", vm.getName()), "vm_delete_completed:" + vm.getId()); } } diff --git a/src/main/java/kr/ac/pusan/pickle/provisioning/DriftFinding.java b/src/main/java/kr/ac/pusan/pickle/provisioning/DriftFinding.java index 19258ae4..dc98ae9a 100644 --- a/src/main/java/kr/ac/pusan/pickle/provisioning/DriftFinding.java +++ b/src/main/java/kr/ac/pusan/pickle/provisioning/DriftFinding.java @@ -9,6 +9,7 @@ import jakarta.persistence.Id; import jakarta.persistence.Table; import java.time.Instant; +import java.util.UUID; import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.type.SqlTypes; @@ -27,6 +28,14 @@ public class DriftFinding { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + /** + * The identifier this row wears outside the API boundary. Internal joins, + * sorts and foreign keys keep using {@link #id}. + */ + @JdbcTypeCode(SqlTypes.UUID) + @Column(name = "public_id", nullable = false, updatable = false, unique = true) + private UUID publicId = UUID.randomUUID(); + @Enumerated(EnumType.STRING) @JdbcTypeCode(SqlTypes.NAMED_ENUM) @Column(nullable = false, columnDefinition = "drift_finding_kind") @@ -82,6 +91,10 @@ public Long getId() { return id; } + public UUID getPublicId() { + return publicId; + } + public DriftFindingKind getKind() { return kind; } diff --git a/src/main/java/kr/ac/pusan/pickle/provisioning/DriftFindingRepository.java b/src/main/java/kr/ac/pusan/pickle/provisioning/DriftFindingRepository.java index a074dc46..ea7a4a1c 100644 --- a/src/main/java/kr/ac/pusan/pickle/provisioning/DriftFindingRepository.java +++ b/src/main/java/kr/ac/pusan/pickle/provisioning/DriftFindingRepository.java @@ -2,6 +2,8 @@ import java.time.Instant; import java.util.Collection; +import java.util.Optional; +import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.Modifying; @@ -18,6 +20,9 @@ public interface DriftFindingRepository extends JpaRepository, JpaSpecificationExecutor { + /** Resolution of the identifier this row wears outside the API boundary. */ + Optional findByPublicId(UUID publicId); + /** * Records one observation of a drift condition: inserts an OPEN finding or * bumps {@code last_seen_at}/{@code summary}/{@code detail} of the existing diff --git a/src/main/java/kr/ac/pusan/pickle/provisioning/ExpiryStopJob.java b/src/main/java/kr/ac/pusan/pickle/provisioning/ExpiryStopJob.java index e2f097c8..82990a6a 100644 --- a/src/main/java/kr/ac/pusan/pickle/provisioning/ExpiryStopJob.java +++ b/src/main/java/kr/ac/pusan/pickle/provisioning/ExpiryStopJob.java @@ -156,7 +156,7 @@ private void finalizeStop(Vm vm) { recipients.addAll(notificationService.vmResponsibleIds(vm)); recipients.addAll(notificationService.orgAdminIds(vm.getOrgId())); notificationService.publish(recipients, NotificationEvent.VM_EXPIRY_STOPPED, - Map.of("vmId", vmId, "vmName", vm.getName(), "endDate", + Map.of("vmId", vm.getPublicId(), "vmName", vm.getName(), "endDate", String.valueOf(vm.getEndDate())), "vm-expiry-stopped:%d:%s".formatted(vmId, vm.getEndDate())); log.info("vm {} auto-stopped: usage period ended {}", vmId, vm.getEndDate()); diff --git a/src/main/java/kr/ac/pusan/pickle/provisioning/ProvisionVmJob.java b/src/main/java/kr/ac/pusan/pickle/provisioning/ProvisionVmJob.java index cb1664da..f6e82044 100644 --- a/src/main/java/kr/ac/pusan/pickle/provisioning/ProvisionVmJob.java +++ b/src/main/java/kr/ac/pusan/pickle/provisioning/ProvisionVmJob.java @@ -727,7 +727,7 @@ private void publishCreated(Vm vm, String ip) { return; } Map args = new LinkedHashMap<>(); - args.put("vmId", vm.getId()); + args.put("vmId", vm.getPublicId()); args.put("hostname", vm.getHostname()); if (ip != null) { args.put("ip", ip); @@ -753,7 +753,7 @@ private void publishCreateFailed(long vmId, String reason) { return; } Map args = new LinkedHashMap<>(); - args.put("vmId", vmId); + args.put("vmId", vm.getPublicId()); args.put("hostname", vm.getHostname()); args.put("reason", reason); notificationService.publish(notificationService.vmOwnerIds(vm), diff --git a/src/main/java/kr/ac/pusan/pickle/provisioning/ProvisioningTask.java b/src/main/java/kr/ac/pusan/pickle/provisioning/ProvisioningTask.java index be6ba537..71bf8964 100644 --- a/src/main/java/kr/ac/pusan/pickle/provisioning/ProvisioningTask.java +++ b/src/main/java/kr/ac/pusan/pickle/provisioning/ProvisioningTask.java @@ -9,6 +9,7 @@ import jakarta.persistence.Id; import jakarta.persistence.Table; import java.time.Instant; +import java.util.UUID; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.annotations.UpdateTimestamp; @@ -29,6 +30,14 @@ public class ProvisioningTask { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + /** + * The identifier this row wears outside the API boundary. Internal joins, + * sorts and foreign keys keep using {@link #id}. + */ + @JdbcTypeCode(SqlTypes.UUID) + @Column(name = "public_id", nullable = false, updatable = false, unique = true) + private UUID publicId = UUID.randomUUID(); + @Column(name = "vm_id", nullable = false) private Long vmId; @@ -75,6 +84,10 @@ public Long getId() { return id; } + public UUID getPublicId() { + return publicId; + } + public Long getVmId() { return vmId; } diff --git a/src/main/java/kr/ac/pusan/pickle/provisioning/ProvisioningTaskRepository.java b/src/main/java/kr/ac/pusan/pickle/provisioning/ProvisioningTaskRepository.java index 7767e60f..1607a24c 100644 --- a/src/main/java/kr/ac/pusan/pickle/provisioning/ProvisioningTaskRepository.java +++ b/src/main/java/kr/ac/pusan/pickle/provisioning/ProvisioningTaskRepository.java @@ -5,6 +5,7 @@ import java.util.List; import java.util.Optional; import java.util.Set; +import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.Modifying; @@ -21,6 +22,9 @@ public interface ProvisioningTaskRepository extends JpaRepository, JpaSpecificationExecutor { + /** Resolution of the identifier this row wears outside the API boundary. */ + Optional findByPublicId(UUID publicId); + List findByVmIdOrderByIdDesc(Long vmId); /** VM ids that currently have a task in the given statuses (poller/reconciler race guard). */ diff --git a/src/main/java/kr/ac/pusan/pickle/provisioning/VmExpiryJob.java b/src/main/java/kr/ac/pusan/pickle/provisioning/VmExpiryJob.java index 864eec9f..92a9d75e 100644 --- a/src/main/java/kr/ac/pusan/pickle/provisioning/VmExpiryJob.java +++ b/src/main/java/kr/ac/pusan/pickle/provisioning/VmExpiryJob.java @@ -125,7 +125,7 @@ private void notifyStage(Vm vm, int stage) { } List recipients = notificationService.vmResponsibleIds(vm); notificationService.publish(recipients, NotificationEvent.VM_EXPIRY_NOTICE, - Map.of("vmId", vm.getId(), "vmName", vm.getName(), + Map.of("vmId", vm.getPublicId(), "vmName", vm.getName(), "endDate", String.valueOf(vm.getEndDate()), "days", stage), "vm-expiry:%d:%s:D%d".formatted(vm.getId(), vm.getEndDate(), stage)); log.info("vm {} expiry notice D-{} sent to {} recipient(s) (end date {})", diff --git a/src/main/java/kr/ac/pusan/pickle/publishing/AdminPublishingController.java b/src/main/java/kr/ac/pusan/pickle/publishing/AdminPublishingController.java index d71149ef..7d8d4905 100644 --- a/src/main/java/kr/ac/pusan/pickle/publishing/AdminPublishingController.java +++ b/src/main/java/kr/ac/pusan/pickle/publishing/AdminPublishingController.java @@ -5,6 +5,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; +import java.util.UUID; import kr.ac.pusan.pickle.auth.dto.MessageResponse; import kr.ac.pusan.pickle.common.web.PageResponse; import kr.ac.pusan.pickle.publishing.dto.AdminCertificateView; @@ -45,7 +46,7 @@ public AdminPublishingController(AdminPublishingService adminPublishingService) @GetMapping("/routes") public PageResponse listAdminRoutes( @AuthenticationPrincipal AuthenticatedUser principal, - @RequestParam(required = false) Long orgId, + @RequestParam(required = false) UUID orgId, @RequestParam(required = false) RouteStatus status, @RequestParam(defaultValue = "0") @Min(0) int page, @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) { @@ -55,7 +56,7 @@ public PageResponse listAdminRoutes( @GetMapping("/domains") public PageResponse listAdminDomains( @AuthenticationPrincipal AuthenticatedUser principal, - @RequestParam(required = false) Long orgId, + @RequestParam(required = false) UUID orgId, @RequestParam(required = false) DomainKind kind, @RequestParam(required = false) DomainStatus status, @RequestParam(defaultValue = "0") @Min(0) int page, @@ -66,7 +67,7 @@ public PageResponse listAdminDomains( @GetMapping("/certificates") public PageResponse listAdminCertificates( @AuthenticationPrincipal AuthenticatedUser principal, - @RequestParam(required = false) Long orgId, + @RequestParam(required = false) UUID orgId, @RequestParam(required = false) CertificateStatus status, @RequestParam(required = false) @Min(1) Integer expiringInDays, @RequestParam(defaultValue = "0") @Min(0) int page, @@ -87,7 +88,7 @@ public MessageResponse resyncRoutes( @PostMapping("/domains/{domainId}/force-release") public MessageResponse forceReleaseDomain( @AuthenticationPrincipal AuthenticatedUser principal, - @PathVariable long domainId, HttpServletRequest httpRequest) { + @PathVariable UUID domainId, HttpServletRequest httpRequest) { return adminPublishingService.forceRelease(principal, domainId, clientIp(httpRequest)); } @@ -96,7 +97,7 @@ public MessageResponse forceReleaseDomain( @ResponseStatus(HttpStatus.ACCEPTED) public MessageResponse verifyAdminDomain( @AuthenticationPrincipal AuthenticatedUser principal, - @PathVariable long domainId, HttpServletRequest httpRequest) { + @PathVariable UUID domainId, HttpServletRequest httpRequest) { return adminPublishingService.verify(principal, domainId, clientIp(httpRequest)); } @@ -105,7 +106,7 @@ public MessageResponse verifyAdminDomain( @ResponseStatus(HttpStatus.ACCEPTED) public MessageResponse applyAdminRoute( @AuthenticationPrincipal AuthenticatedUser principal, - @PathVariable long routeId, HttpServletRequest httpRequest) { + @PathVariable UUID routeId, HttpServletRequest httpRequest) { return adminPublishingService.applyRoute(principal, routeId, clientIp(httpRequest)); } } diff --git a/src/main/java/kr/ac/pusan/pickle/publishing/AdminPublishingService.java b/src/main/java/kr/ac/pusan/pickle/publishing/AdminPublishingService.java index 3bae1e41..343031f5 100644 --- a/src/main/java/kr/ac/pusan/pickle/publishing/AdminPublishingService.java +++ b/src/main/java/kr/ac/pusan/pickle/publishing/AdminPublishingService.java @@ -5,6 +5,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.UUID; import java.util.function.Function; import java.util.stream.Collectors; import kr.ac.pusan.pickle.audit.AuditService; @@ -90,7 +91,7 @@ public AdminPublishingService(RouteRepository routeRepository, DomainRepository } @Transactional(readOnly = true) - public PageResponse listRoutes(AuthenticatedUser actor, Long orgId, + public PageResponse listRoutes(AuthenticatedUser actor, UUID orgId, RouteStatus status, int page, int size) { Long scopedOrgId = scopedOrgId(actor, orgId); Page routes = routeRepository.findAdmin(scopedOrgId, name(status), page(page, size)); @@ -100,12 +101,13 @@ public PageResponse listRoutes(AuthenticatedUser actor, Long org List content = routes.getContent().stream().map(route -> { Domain domain = ctx.domains.get(route.getDomainId()); Vm vm = domain != null ? ctx.vms.get(domain.getVmId()) : null; - return new AdminRouteView(route.getId(), route.getDomainId(), + return new AdminRouteView(route.getPublicId(), + domain != null ? domain.getPublicId() : null, domain != null ? domain.getFqdn() : null, domain != null ? domain.getKind() : null, - vm != null ? vm.getId() : null, name(vm), - vm != null ? vm.getWorkspaceId() : null, ctx.workspaceName(vm), - vm != null ? vm.getOrgId() : null, ctx.orgName(vm), + vm != null ? vm.getPublicId() : null, name(vm), + ctx.workspaceId(vm), ctx.workspaceName(vm), + ctx.orgId(vm), ctx.orgName(vm), route.getTargetPort(), route.getProtocol(), route.getStatus(), route.getAppliedGeneration(), route.getAppliedAt(), route.getLastError(), route.getUpdatedAt()); @@ -121,7 +123,7 @@ public PageResponse listRoutes(AuthenticatedUser actor, Long org * identically, and "why is this subdomain taken" has no answer here. */ @Transactional(readOnly = true) - public PageResponse listDomains(AuthenticatedUser actor, Long orgId, + public PageResponse listDomains(AuthenticatedUser actor, UUID orgId, DomainKind kind, DomainStatus status, int page, int size) { Long scopedOrgId = scopedOrgId(actor, orgId); Page domains = domainRepository.findAdmin(scopedOrgId, name(kind), name(status), @@ -133,19 +135,20 @@ public PageResponse listDomains(AuthenticatedUser actor, Long o .findFirstByDomainIdAndStatusNot(domain.getId(), RouteStatus.REMOVED) .map(Route::getStatus).orElse(null); var certStatus = assembler.certificateFor(domain).map(Certificate::getStatus).orElse(null); - return new AdminDomainView(domain.getId(), domain.getVmId(), domain.getKind(), + return new AdminDomainView(domain.getPublicId(), + vm != null ? vm.getPublicId() : null, domain.getKind(), domain.getFqdn(), domain.getRootDomain(), domain.getStatus(), domain.getVerifiedAt(), domain.getReleasedAt(), assembler.reservedUntil(domain), domain.getCreatedAt(), name(vm), - vm != null ? vm.getWorkspaceId() : null, ctx.workspaceName(vm), - vm != null ? vm.getOrgId() : null, ctx.orgName(vm), + ctx.workspaceId(vm), ctx.workspaceName(vm), + ctx.orgId(vm), ctx.orgName(vm), routeStatus, certStatus, domain.getUpdatedAt()); }).toList(); return PageResponse.of(content, domains); } @Transactional(readOnly = true) - public PageResponse listCertificates(AuthenticatedUser actor, Long orgId, + public PageResponse listCertificates(AuthenticatedUser actor, UUID orgId, CertificateStatus status, Integer expiringInDays, int page, int size) { Long scopedOrgId = scopedOrgId(actor, orgId); Instant now = Instant.now(); @@ -153,9 +156,15 @@ public PageResponse listCertificates(AuthenticatedUser act ? certificateRepository.findAdminExpiring(scopedOrgId, name(status), now.plus(expiringInDays, ChronoUnit.DAYS), page(page, size)) : certificateRepository.findAdmin(scopedOrgId, name(status), page(page, size)); + // The certificate names its domain by public id, and unlike its sibling + // listings this one had no domain load at all — hence the batch. + Map domainIds = domainRepository.findAllById(certs.getContent().stream() + .map(Certificate::getDomainId).filter(java.util.Objects::nonNull) + .distinct().toList()).stream() + .collect(Collectors.toMap(Domain::getId, Domain::getPublicId)); List content = certs.getContent().stream() - .map(cert -> new AdminCertificateView(cert.getId(), cert.getKind(), cert.getStatus(), - cert.getScope(), cert.getDomainId(), cert.getNotAfter(), + .map(cert -> new AdminCertificateView(cert.getPublicId(), cert.getKind(), cert.getStatus(), + cert.getScope(), domainIds.get(cert.getDomainId()), cert.getNotAfter(), // a FAILED cert has no meaningful expiry countdown cert.getStatus() == CertificateStatus.FAILED ? null : daysUntilExpiry(now, cert.getNotAfter()), @@ -190,7 +199,7 @@ public void afterCommit() { * gets none — same as a user's immediate return). */ @Transactional - public MessageResponse forceRelease(AuthenticatedUser actor, long domainId, String ip) { + public MessageResponse forceRelease(AuthenticatedUser actor, UUID domainId, String ip) { Domain domain = requireScopedDomain(actor, domainId); boolean served = assembler.hasLiveRoute(domain); publishingService.forceTeardown(domain); @@ -203,12 +212,12 @@ public MessageResponse forceRelease(AuthenticatedUser actor, long domainId, Stri notificationService.publish( notificationService.vmResponsibleIds(vm), NotificationEvent.DOMAIN_ADMIN_RELEASED, - Map.of("vmId", vm.getId(), "vmName", vm.getName(), + Map.of("vmId", vm.getPublicId(), "vmName", vm.getName(), "fqdn", domain.getFqdn()), null); } auditService.recordAfterCommit(actor.id(), actor.role().name(), - AuditService.DOMAIN_FORCE_RELEASE, "domain", domainId, + AuditService.DOMAIN_FORCE_RELEASE, "domain", domain.getPublicId(), Map.of("fqdn", domain.getFqdn()), ip); return new MessageResponse("도메인을 강제 해제했습니다. 라우트 제거가 곧 적용되며, 이름은 즉시 회수됩니다."); } @@ -219,15 +228,16 @@ public MessageResponse forceRelease(AuthenticatedUser actor, long domainId, Stri * in {@link DomainVerificationJob} still bounds the load. */ @Transactional - public MessageResponse verify(AuthenticatedUser actor, long domainId, String ip) { - Domain domain = requireScopedDomain(actor, domainId); + public MessageResponse verify(AuthenticatedUser actor, UUID publicDomainId, String ip) { + Domain domain = requireScopedDomain(actor, publicDomainId); + long domainId = domain.getId(); if (domain.getKind() != DomainKind.CUSTOM) { throw new ApiException(HttpStatus.CONFLICT, ErrorCodes.DOMAIN_NOT_CUSTOM, "검증할 수 없는 도메인입니다", "플랫폼 서브도메인은 소유권 검증이 필요하지 않습니다."); } runAfterCommit(() -> domainVerificationJob.requestVerify(domainId)); auditService.recordAfterCommit(actor.id(), actor.role().name(), - AuditService.DOMAIN_ADMIN_VERIFY, "domain", domainId, + AuditService.DOMAIN_ADMIN_VERIFY, "domain", domain.getPublicId(), Map.of("fqdn", domain.getFqdn()), ip); return new MessageResponse("소유권 재검증을 접수했습니다. 잠시 후 상태가 갱신됩니다."); } @@ -243,8 +253,8 @@ public MessageResponse verify(AuthenticatedUser actor, long domainId, String ip) * answers 409. */ @Transactional - public MessageResponse applyRoute(AuthenticatedUser actor, long routeId, String ip) { - Route route = routeRepository.findById(routeId) + public MessageResponse applyRoute(AuthenticatedUser actor, UUID routeId, String ip) { + Route route = routeRepository.findByPublicId(routeId) .orElseThrow(AdminPublishingService::routeNotFound); Domain domain = domainRepository.findById(route.getDomainId()) .orElseThrow(AdminPublishingService::routeNotFound); @@ -269,7 +279,7 @@ public void afterCommit() { } }); auditService.recordAfterCommit(actor.id(), actor.role().name(), AuditService.ROUTE_APPLY, - "route", routeId, Map.of("fqdn", domain.getFqdn()), ip); + "route", route.getPublicId(), Map.of("fqdn", domain.getFqdn()), ip); return new MessageResponse("라우트 재적용을 접수했습니다. 잠시 후 적용 상태가 갱신됩니다."); } @@ -278,8 +288,8 @@ public void afterCommit() { * domain, and an org-tier actor naming another org's domain all answer the * same 404. */ - private Domain requireScopedDomain(AuthenticatedUser actor, long domainId) { - Domain domain = domainRepository.findById(domainId) + private Domain requireScopedDomain(AuthenticatedUser actor, UUID domainId) { + Domain domain = domainRepository.findByPublicId(domainId) .orElseThrow(AdminPublishingService::domainNotFound); if (domain.getStatus() == DomainStatus.REMOVED) { throw domainNotFound(); @@ -319,7 +329,7 @@ private static ApiException routeNotFound() { // ── helpers ────────────────────────────────────────────────────────────── - private Long scopedOrgId(AuthenticatedUser actor, Long orgId) { + private Long scopedOrgId(AuthenticatedUser actor, UUID orgId) { if (actor.role().isOrgTier()) { if (actor.orgId() == null) { throw new ApiException(HttpStatus.FORBIDDEN, ErrorCodes.ACCESS_DENIED, @@ -327,7 +337,9 @@ private Long scopedOrgId(AuthenticatedUser actor, Long orgId) { } return actor.orgId(); } - return orgId; + // An id no org has filters to nothing, as a non-matching number did. + return orgId == null ? null + : orgRepository.findByPublicId(orgId).map(Org::getId).orElse(-1L); } private static Pageable page(int page, int size) { @@ -381,5 +393,15 @@ String orgName(Vm vm) { Org org = orgs.get(vm.getOrgId()); return org != null ? org.getName() : null; } + + UUID workspaceId(Vm vm) { + Workspace workspace = vm == null ? null : workspaces.get(vm.getWorkspaceId()); + return workspace != null ? workspace.getPublicId() : null; + } + + UUID orgId(Vm vm) { + Org org = vm == null ? null : orgs.get(vm.getOrgId()); + return org != null ? org.getPublicId() : null; + } } } diff --git a/src/main/java/kr/ac/pusan/pickle/publishing/Certificate.java b/src/main/java/kr/ac/pusan/pickle/publishing/Certificate.java index 2a684d7d..bddaec47 100644 --- a/src/main/java/kr/ac/pusan/pickle/publishing/Certificate.java +++ b/src/main/java/kr/ac/pusan/pickle/publishing/Certificate.java @@ -9,6 +9,7 @@ import jakarta.persistence.Id; import jakarta.persistence.Table; import java.time.Instant; +import java.util.UUID; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.annotations.UpdateTimestamp; @@ -28,6 +29,14 @@ public class Certificate { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + /** + * The identifier this row wears outside the API boundary. Internal joins, + * sorts and foreign keys keep using {@link #id}. + */ + @JdbcTypeCode(SqlTypes.UUID) + @Column(name = "public_id", nullable = false, updatable = false, unique = true) + private UUID publicId = UUID.randomUUID(); + @Column(name = "domain_id") private Long domainId; @@ -75,6 +84,10 @@ public Long getId() { return id; } + public UUID getPublicId() { + return publicId; + } + public Long getDomainId() { return domainId; } diff --git a/src/main/java/kr/ac/pusan/pickle/publishing/CertificateRepository.java b/src/main/java/kr/ac/pusan/pickle/publishing/CertificateRepository.java index 48c71a13..b113131e 100644 --- a/src/main/java/kr/ac/pusan/pickle/publishing/CertificateRepository.java +++ b/src/main/java/kr/ac/pusan/pickle/publishing/CertificateRepository.java @@ -3,6 +3,7 @@ import java.time.Instant; import java.util.List; import java.util.Optional; +import java.util.UUID; import org.springframework.data.domain.Limit; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -12,6 +13,9 @@ public interface CertificateRepository extends JpaRepository { + /** Resolution of the identifier this row wears outside the API boundary. */ + Optional findByPublicId(UUID publicId); + /** The current cert for a custom domain (excludes revoked/archived). */ Optional findFirstByDomainIdAndStatusNot(Long domainId, CertificateStatus status); diff --git a/src/main/java/kr/ac/pusan/pickle/publishing/Domain.java b/src/main/java/kr/ac/pusan/pickle/publishing/Domain.java index a9f9f039..06867a55 100644 --- a/src/main/java/kr/ac/pusan/pickle/publishing/Domain.java +++ b/src/main/java/kr/ac/pusan/pickle/publishing/Domain.java @@ -9,6 +9,7 @@ import jakarta.persistence.Id; import jakarta.persistence.Table; import java.time.Instant; +import java.util.UUID; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.annotations.UpdateTimestamp; @@ -27,6 +28,14 @@ public class Domain { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + /** + * The identifier this row wears outside the API boundary. Internal joins, + * sorts and foreign keys keep using {@link #id}. + */ + @JdbcTypeCode(SqlTypes.UUID) + @Column(name = "public_id", nullable = false, updatable = false, unique = true) + private UUID publicId = UUID.randomUUID(); + @Column(name = "vm_id", nullable = false) private Long vmId; @@ -108,6 +117,10 @@ public Long getId() { return id; } + public UUID getPublicId() { + return publicId; + } + public Long getVmId() { return vmId; } diff --git a/src/main/java/kr/ac/pusan/pickle/publishing/DomainRepository.java b/src/main/java/kr/ac/pusan/pickle/publishing/DomainRepository.java index 4cbd089a..10475360 100644 --- a/src/main/java/kr/ac/pusan/pickle/publishing/DomainRepository.java +++ b/src/main/java/kr/ac/pusan/pickle/publishing/DomainRepository.java @@ -4,6 +4,7 @@ import java.util.Collection; import java.util.List; import java.util.Optional; +import java.util.UUID; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; @@ -13,6 +14,9 @@ public interface DomainRepository extends JpaRepository { + /** Resolution of the identifier this row wears outside the API boundary. */ + Optional findByPublicId(UUID publicId); + /** The live row holding an FQDN — the revive-or-409 pre-check. */ Optional findFirstByFqdnAndStatusNot(String fqdn, DomainStatus status); diff --git a/src/main/java/kr/ac/pusan/pickle/publishing/DomainReservationSweeper.java b/src/main/java/kr/ac/pusan/pickle/publishing/DomainReservationSweeper.java index bfcf5938..b32d4bdf 100644 --- a/src/main/java/kr/ac/pusan/pickle/publishing/DomainReservationSweeper.java +++ b/src/main/java/kr/ac/pusan/pickle/publishing/DomainReservationSweeper.java @@ -144,7 +144,7 @@ private void notify(Domain domain, NotificationEvent event, Instant reservedUnti return; } notificationService.publish(notificationService.vmResponsibleIds(vm), - event, Map.of("fqdn", domain.getFqdn(), "vmId", vm.getId(), + event, Map.of("fqdn", domain.getFqdn(), "vmId", vm.getPublicId(), "reservedUntil", reservedUntil), dedupKey); } } diff --git a/src/main/java/kr/ac/pusan/pickle/publishing/DomainVerifier.java b/src/main/java/kr/ac/pusan/pickle/publishing/DomainVerifier.java index 12caadf1..c8fecd85 100644 --- a/src/main/java/kr/ac/pusan/pickle/publishing/DomainVerifier.java +++ b/src/main/java/kr/ac/pusan/pickle/publishing/DomainVerifier.java @@ -169,7 +169,7 @@ private void notifyVerificationFailed(Domain domain, String error) { } notificationService.publish(notificationService.vmResponsibleIds(vm), NotificationEvent.DOMAIN_CONNECT_FAILED, - Map.of("fqdn", domain.getFqdn(), "vmId", vm.getId(), "reason", error), + Map.of("fqdn", domain.getFqdn(), "vmId", vm.getPublicId(), "reason", error), "domain_verify_failed:" + domain.getId()); } diff --git a/src/main/java/kr/ac/pusan/pickle/publishing/PublicationAssembler.java b/src/main/java/kr/ac/pusan/pickle/publishing/PublicationAssembler.java index 0b98647f..4204316f 100644 --- a/src/main/java/kr/ac/pusan/pickle/publishing/PublicationAssembler.java +++ b/src/main/java/kr/ac/pusan/pickle/publishing/PublicationAssembler.java @@ -4,6 +4,7 @@ import java.time.temporal.ChronoUnit; import java.util.List; import java.util.Optional; +import java.util.UUID; import kr.ac.pusan.pickle.config.PublishingProperties; import kr.ac.pusan.pickle.publishing.dto.CertificateView; import kr.ac.pusan.pickle.publishing.dto.DomainDetailView; @@ -52,17 +53,17 @@ public PublicationAssembler(RouteRepository routeRepository, this.settingsService = settingsService; } - public DomainSummaryView toDomainSummary(Domain domain) { - return new DomainSummaryView(domain.getId(), domain.getVmId(), domain.getKind(), + public DomainSummaryView toDomainSummary(Domain domain, UUID vmId) { + return new DomainSummaryView(domain.getPublicId(), vmId, domain.getKind(), domain.getFqdn(), domain.getRootDomain(), domain.getStatus(), domain.getVerifiedAt(), domain.getReleasedAt(), reservedUntil(domain), domain.getCreatedAt()); } - public DomainDetailView toDomainDetail(Domain domain) { + public DomainDetailView toDomainDetail(Domain domain, UUID vmId) { DomainVerificationView verification = domain.getKind() == DomainKind.CUSTOM ? verification(domain) : null; - return new DomainDetailView(domain.getId(), domain.getVmId(), domain.getKind(), + return new DomainDetailView(domain.getPublicId(), vmId, domain.getKind(), domain.getFqdn(), domain.getRootDomain(), domain.getStatus(), domain.getVerifiedAt(), domain.getReleasedAt(), reservedUntil(domain), domain.getCreatedAt(), verification); @@ -93,12 +94,12 @@ Instant reservedUntil(Domain domain) { } /** The full publish view for a domain — its live route and certificate. */ - public PublicationView toPublication(Domain domain) { + public PublicationView toPublication(Domain domain, UUID vmId) { RouteView route = routeRepository.findFirstByDomainIdAndStatusNot(domain.getId(), RouteStatus.REMOVED) .map(RouteView::from) .orElse(null); CertificateView certificate = certificateFor(domain).map(CertificateView::from).orElse(null); - return new PublicationView(domain.getFqdn(), toDomainDetail(domain), route, certificate); + return new PublicationView(domain.getFqdn(), toDomainDetail(domain, vmId), route, certificate); } /** diff --git a/src/main/java/kr/ac/pusan/pickle/publishing/PublishingController.java b/src/main/java/kr/ac/pusan/pickle/publishing/PublishingController.java index 7c89042f..5b881ffd 100644 --- a/src/main/java/kr/ac/pusan/pickle/publishing/PublishingController.java +++ b/src/main/java/kr/ac/pusan/pickle/publishing/PublishingController.java @@ -5,6 +5,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; +import java.util.UUID; import kr.ac.pusan.pickle.auth.dto.MessageResponse; import kr.ac.pusan.pickle.common.web.PageResponse; import kr.ac.pusan.pickle.publishing.dto.CreateVmDomainRequest; @@ -46,7 +47,7 @@ public PublishingController(PublishingService publishingService) { @PostMapping("/vms/{vmId}/domains") @ResponseStatus(HttpStatus.ACCEPTED) public PublicationView createVmDomain( - @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable long vmId, + @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable UUID vmId, @RequestBody(required = false) CreateVmDomainRequest request, HttpServletRequest httpRequest) { CreateVmDomainRequest body = request != null ? request @@ -59,7 +60,7 @@ public PublicationView createVmDomain( @PatchMapping("/domains/{domainId}") @ResponseStatus(HttpStatus.ACCEPTED) public PublicationView updateDomain( - @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable long domainId, + @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable UUID domainId, @RequestBody UpdateDomainRequest request, HttpServletRequest httpRequest) { return publishingService.updateDomain(principal, domainId, request.port(), clientIp(httpRequest)); @@ -68,7 +69,7 @@ public PublicationView updateDomain( @GetMapping("/domains") public PageResponse listDomains( @AuthenticationPrincipal AuthenticatedUser principal, - @RequestParam(required = false) Long vmId, + @RequestParam(required = false) UUID vmId, @RequestParam(required = false) DomainStatus status, @RequestParam(defaultValue = "0") @Min(0) int page, @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) { @@ -77,14 +78,14 @@ public PageResponse listDomains( @GetMapping("/domains/{domainId}") public DomainDetailView getDomain(@AuthenticationPrincipal AuthenticatedUser principal, - @PathVariable long domainId) { + @PathVariable UUID domainId) { return publishingService.getDomain(principal, domainId); } @DeleteMapping("/domains/{domainId}") @ResponseStatus(HttpStatus.ACCEPTED) public MessageResponse deleteDomain( - @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable long domainId, + @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable UUID domainId, HttpServletRequest httpRequest) { return publishingService.deleteDomain(principal, domainId, clientIp(httpRequest)); } @@ -92,7 +93,7 @@ public MessageResponse deleteDomain( @PostMapping("/domains/{domainId}/verify") @ResponseStatus(HttpStatus.ACCEPTED) public DomainDetailView verifyDomain( - @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable long domainId, + @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable UUID domainId, HttpServletRequest httpRequest) { return publishingService.verifyDomain(principal, domainId, clientIp(httpRequest)); } diff --git a/src/main/java/kr/ac/pusan/pickle/publishing/PublishingService.java b/src/main/java/kr/ac/pusan/pickle/publishing/PublishingService.java index 778b2161..0a192a88 100644 --- a/src/main/java/kr/ac/pusan/pickle/publishing/PublishingService.java +++ b/src/main/java/kr/ac/pusan/pickle/publishing/PublishingService.java @@ -9,6 +9,7 @@ import java.util.Locale; import java.util.Map; import java.util.Set; +import java.util.UUID; import java.util.regex.Pattern; import kr.ac.pusan.pickle.access.ResourceRole; import kr.ac.pusan.pickle.access.AccessGranteeType; @@ -127,9 +128,10 @@ public PublishingService(VmRepository vmRepository, WorkspaceMemberRepository wo // ── create / update / delete a domain ──────────────────────────────────── @Transactional - public PublicationView createDomain(AuthenticatedUser actor, long vmId, Integer port, + public PublicationView createDomain(AuthenticatedUser actor, UUID publicVmId, Integer port, String subdomain, String rootDomain, String customDomain, String ip) { - Vm vm = requireVmOwnerOrEditor(actor, vmId); + Vm vm = requireVmOwnerOrEditor(actor, publicVmId); + long vmId = vm.getId(); requirePublishableState(vm); if (Texts.blankToNull(subdomain) != null && Texts.blankToNull(customDomain) != null) { throw ApiException.validationFailed(List.of(new FieldValidationError("subdomain", @@ -142,16 +144,17 @@ public PublicationView createDomain(AuthenticatedUser actor, long vmId, Integer : createPlatform(vm, resolvedPort, subdomain, rootDomain); vmEventRepository.save(new VmEvent(vmId, VmEventType.PUBLISH, actor.id(), domain.getFqdn())); auditService.recordAfterCommit(actor.id(), actor.role().name(), AuditService.VM_PUBLISH, - "vm", vmId, Map.of("fqdn", domain.getFqdn(), "port", resolvedPort, + "vm", vm.getPublicId(), Map.of("fqdn", domain.getFqdn(), "port", resolvedPort, "kind", domain.getKind().name()), ip); - return assembler.toPublication(domain); + return assembler.toPublication(domain, vm.getPublicId()); } @Transactional - public PublicationView updateDomain(AuthenticatedUser actor, long domainId, Integer port, + public PublicationView updateDomain(AuthenticatedUser actor, UUID publicDomainId, Integer port, String ip) { - Domain domain = domainRepository.findById(domainId).orElseThrow(PublishingService::domainNotFound); - Vm vm = requireVmOwnerOrEditor(actor, domain.getVmId()); + Domain domain = domainRepository.findByPublicId(publicDomainId) + .orElseThrow(PublishingService::domainNotFound); + Vm vm = requireVmOwnerOrEditorOf(actor, domain.getVmId()); if (domain.getStatus() == DomainStatus.REMOVED) { throw domainNotFound(); } @@ -176,8 +179,8 @@ public PublicationView updateDomain(AuthenticatedUser actor, long domainId, Inte enqueueAfterCommit(() -> routeApplyJob.apply(routeId)); } auditService.recordAfterCommit(actor.id(), actor.role().name(), AuditService.DOMAIN_UPDATE, - "domain", domainId, Map.of("fqdn", domain.getFqdn(), "port", resolvedPort), ip); - return assembler.toPublication(domain); + "domain", domain.getPublicId(), Map.of("fqdn", domain.getFqdn(), "port", resolvedPort), ip); + return assembler.toPublication(domain, vm.getPublicId()); } /** @@ -189,9 +192,10 @@ public PublicationView updateDomain(AuthenticatedUser actor, long domainId, Inte * row) is removed outright, freeing the name immediately. */ @Transactional - public MessageResponse deleteDomain(AuthenticatedUser actor, long domainId, String ip) { - Domain domain = domainRepository.findById(domainId).orElseThrow(PublishingService::domainNotFound); - requireVmOwnerOrEditor(actor, domain.getVmId()); + public MessageResponse deleteDomain(AuthenticatedUser actor, UUID publicDomainId, String ip) { + Domain domain = domainRepository.findByPublicId(publicDomainId) + .orElseThrow(PublishingService::domainNotFound); + requireVmOwnerOrEditorOf(actor, domain.getVmId()); if (domain.getStatus() == DomainStatus.REMOVED) { throw domainNotFound(); } @@ -202,7 +206,7 @@ public MessageResponse deleteDomain(AuthenticatedUser actor, long domainId, Stri domain.getFqdn())); } auditService.recordAfterCommit(actor.id(), actor.role().name(), AuditService.DOMAIN_DELETE, - "domain", domainId, Map.of("fqdn", domain.getFqdn()), ip); + "domain", domain.getPublicId(), Map.of("fqdn", domain.getFqdn()), ip); return domain.getReleasedAt() != null && domain.getStatus() != DomainStatus.REMOVED ? new MessageResponse("도메인 해제를 접수했습니다. 이름은 유예 기간 동안 이 VM에 예약됩니다.") : new MessageResponse("도메인 삭제를 접수했습니다."); @@ -211,26 +215,36 @@ public MessageResponse deleteDomain(AuthenticatedUser actor, long domainId, Stri // ── domain reads ───────────────────────────────────────────────────────── @Transactional(readOnly = true) - public PageResponse listDomains(AuthenticatedUser actor, Long vmId, + public PageResponse listDomains(AuthenticatedUser actor, UUID publicVmId, DomainStatus status, int page, int size) { Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "id")); + // An id no VM has filters to nothing, as a non-matching number did. + Long vmId = publicVmId == null ? null + : vmRepository.findByPublicId(publicVmId).map(Vm::getId).orElse(-1L); Page result = domainRepository.findForReachableVms(reachableVmIds(actor), vmId, status != null ? status.name() : null, pageable); - return PageResponse.of(result.getContent().stream().map(assembler::toDomainSummary).toList(), - result); + Map vmPublicIds = vmRepository.findAllById(result.getContent().stream() + .map(Domain::getVmId).distinct().toList()).stream() + .collect(java.util.stream.Collectors.toMap(Vm::getId, Vm::getPublicId)); + return PageResponse.of(result.getContent().stream() + .map(domain -> assembler.toDomainSummary(domain, vmPublicIds.get(domain.getVmId()))) + .toList(), result); } @Transactional(readOnly = true) - public DomainDetailView getDomain(AuthenticatedUser actor, long domainId) { - Domain domain = domainRepository.findById(domainId).orElseThrow(PublishingService::domainNotFound); - requireVmMember(actor, domain.getVmId()); - return assembler.toDomainDetail(domain); + public DomainDetailView getDomain(AuthenticatedUser actor, UUID publicDomainId) { + Domain domain = domainRepository.findByPublicId(publicDomainId) + .orElseThrow(PublishingService::domainNotFound); + Vm vm = requireVmMemberOf(actor, domain.getVmId()); + return assembler.toDomainDetail(domain, vm.getPublicId()); } @Transactional - public DomainDetailView verifyDomain(AuthenticatedUser actor, long domainId, String ip) { - Domain domain = domainRepository.findById(domainId).orElseThrow(PublishingService::domainNotFound); - requireVmOwnerOrEditor(actor, domain.getVmId()); + public DomainDetailView verifyDomain(AuthenticatedUser actor, UUID publicDomainId, String ip) { + Domain domain = domainRepository.findByPublicId(publicDomainId) + .orElseThrow(PublishingService::domainNotFound); + Vm vm = requireVmOwnerOrEditorOf(actor, domain.getVmId()); + long domainId = domain.getId(); // Same 404 mask as update/delete: a REMOVED row is gone to its owner. if (domain.getStatus() == DomainStatus.REMOVED) { throw domainNotFound(); @@ -245,8 +259,8 @@ public DomainDetailView verifyDomain(AuthenticatedUser actor, long domainId, Str RateLimitService.DEFAULT_LIMIT_PER_MINUTE); runAfterCommit(() -> domainVerificationJob.requestVerify(domainId)); auditService.recordAfterCommit(actor.id(), actor.role().name(), AuditService.DOMAIN_VERIFY, - "domain", domainId, Map.of("fqdn", domain.getFqdn()), ip); - return assembler.toDomainDetail(domain); + "domain", domain.getPublicId(), Map.of("fqdn", domain.getFqdn()), ip); + return assembler.toDomainDetail(domain, vm.getPublicId()); } // ── shared creation core ───────────────────────────────────────────────── @@ -580,11 +594,24 @@ private static ApiException fqdnTaken() { // ── authorization helpers ──────────────────────────────────────────────── - private Vm requireVmMember(AuthenticatedUser actor, long vmId) { + private Vm requireVmMember(AuthenticatedUser actor, UUID vmId) { return vmAccessService.of(actor, vmId).requireVisible(); } - private Vm requireVmOwnerOrEditor(AuthenticatedUser actor, long vmId) { + /** The same two gates reached from a row that already names its VM internally. */ + private Vm requireVmMemberOf(AuthenticatedUser actor, long vmId) { + return vmAccessService.of(vmRepository.findById(vmId) + .orElseThrow(VmAccessService::vmNotFound), actor.id()).requireVisible(); + } + + private Vm requireVmOwnerOrEditorOf(AuthenticatedUser actor, long vmId) { + return vmAccessService.of(vmRepository.findById(vmId) + .orElseThrow(VmAccessService::vmNotFound), actor.id()) + .requireAtLeast(ResourceRole.EDITOR, + "도메인을 관리할 권한이 없습니다", "이 VM의 편집자 이상만 도메인을 관리할 수 있습니다."); + } + + private Vm requireVmOwnerOrEditor(AuthenticatedUser actor, UUID vmId) { return vmAccessService.of(actor, vmId).requireAtLeast(ResourceRole.EDITOR, "HTTP 서비스를 공개할 권한이 없습니다", "이 VM의 소유자 또는 편집자만 도메인·포트를 설정할 수 있습니다."); diff --git a/src/main/java/kr/ac/pusan/pickle/publishing/Route.java b/src/main/java/kr/ac/pusan/pickle/publishing/Route.java index ad1a4971..41d1da1e 100644 --- a/src/main/java/kr/ac/pusan/pickle/publishing/Route.java +++ b/src/main/java/kr/ac/pusan/pickle/publishing/Route.java @@ -9,6 +9,7 @@ import jakarta.persistence.Id; import jakarta.persistence.Table; import java.time.Instant; +import java.util.UUID; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.annotations.UpdateTimestamp; @@ -29,6 +30,14 @@ public class Route { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + /** + * The identifier this row wears outside the API boundary. Internal joins, + * sorts and foreign keys keep using {@link #id}. + */ + @JdbcTypeCode(SqlTypes.UUID) + @Column(name = "public_id", nullable = false, updatable = false, unique = true) + private UUID publicId = UUID.randomUUID(); + @Column(name = "domain_id", nullable = false) private Long domainId; @@ -78,6 +87,10 @@ public Long getId() { return id; } + public UUID getPublicId() { + return publicId; + } + public Long getDomainId() { return domainId; } diff --git a/src/main/java/kr/ac/pusan/pickle/publishing/RouteApplyJob.java b/src/main/java/kr/ac/pusan/pickle/publishing/RouteApplyJob.java index ae82d447..5e3894cd 100644 --- a/src/main/java/kr/ac/pusan/pickle/publishing/RouteApplyJob.java +++ b/src/main/java/kr/ac/pusan/pickle/publishing/RouteApplyJob.java @@ -368,8 +368,8 @@ private void notifyDomainOutcome(Domain domain, NotificationEvent event, String return; } Map args = reason == null - ? Map.of("fqdn", domain.getFqdn(), "vmId", vm.getId()) - : Map.of("fqdn", domain.getFqdn(), "vmId", vm.getId(), "reason", reason); + ? Map.of("fqdn", domain.getFqdn(), "vmId", vm.getPublicId()) + : Map.of("fqdn", domain.getFqdn(), "vmId", vm.getPublicId(), "reason", reason); notificationService.publish(notificationService.vmResponsibleIds(vm), event, args, dedupKey); } diff --git a/src/main/java/kr/ac/pusan/pickle/publishing/RouteRepository.java b/src/main/java/kr/ac/pusan/pickle/publishing/RouteRepository.java index b921357e..28dec3a4 100644 --- a/src/main/java/kr/ac/pusan/pickle/publishing/RouteRepository.java +++ b/src/main/java/kr/ac/pusan/pickle/publishing/RouteRepository.java @@ -4,6 +4,7 @@ import java.time.Instant; import java.util.List; import java.util.Optional; +import java.util.UUID; import org.springframework.data.domain.Page; import org.springframework.data.jpa.repository.Lock; import org.springframework.data.domain.Pageable; @@ -15,6 +16,9 @@ public interface RouteRepository extends JpaRepository { + /** Resolution of the identifier this row wears outside the API boundary. */ + Optional findByPublicId(UUID publicId); + /** * The route, locked for one SHORT apply phase — the prepare that reads the * desired state and the record that writes the outcome each take this lock diff --git a/src/main/java/kr/ac/pusan/pickle/publishing/dto/AdminCertificateView.java b/src/main/java/kr/ac/pusan/pickle/publishing/dto/AdminCertificateView.java index 972f18dd..bdbf2262 100644 --- a/src/main/java/kr/ac/pusan/pickle/publishing/dto/AdminCertificateView.java +++ b/src/main/java/kr/ac/pusan/pickle/publishing/dto/AdminCertificateView.java @@ -1,17 +1,18 @@ package kr.ac.pusan.pickle.publishing.dto; import java.time.Instant; +import java.util.UUID; import kr.ac.pusan.pickle.publishing.CertificateKind; import kr.ac.pusan.pickle.publishing.CertificateStatus; import org.jspecify.annotations.Nullable; /** Contract schema {@code AdminCertificateView}. */ public record AdminCertificateView( - Long id, + UUID id, CertificateKind kind, CertificateStatus status, String scope, - @Nullable Long domainId, + @Nullable UUID domainId, @Nullable Instant notAfter, @Nullable Integer daysUntilExpiry, @Nullable String lastError) { diff --git a/src/main/java/kr/ac/pusan/pickle/publishing/dto/AdminDomainView.java b/src/main/java/kr/ac/pusan/pickle/publishing/dto/AdminDomainView.java index dae26a33..5927b263 100644 --- a/src/main/java/kr/ac/pusan/pickle/publishing/dto/AdminDomainView.java +++ b/src/main/java/kr/ac/pusan/pickle/publishing/dto/AdminDomainView.java @@ -1,6 +1,7 @@ package kr.ac.pusan.pickle.publishing.dto; import java.time.Instant; +import java.util.UUID; import kr.ac.pusan.pickle.publishing.CertificateStatus; import kr.ac.pusan.pickle.publishing.DomainKind; import kr.ac.pusan.pickle.publishing.DomainStatus; @@ -18,8 +19,8 @@ * occupied.

*/ public record AdminDomainView( - Long id, - Long vmId, + UUID id, + UUID vmId, DomainKind kind, String fqdn, @Nullable String rootDomain, @@ -29,9 +30,9 @@ public record AdminDomainView( @Nullable Instant reservedUntil, Instant createdAt, String vmName, - Long workspaceId, + UUID workspaceId, String workspaceName, - Long orgId, + UUID orgId, String orgName, @Nullable RouteStatus routeStatus, @Nullable CertificateStatus certificateStatus, diff --git a/src/main/java/kr/ac/pusan/pickle/publishing/dto/AdminRouteView.java b/src/main/java/kr/ac/pusan/pickle/publishing/dto/AdminRouteView.java index 941e75e8..4d58151a 100644 --- a/src/main/java/kr/ac/pusan/pickle/publishing/dto/AdminRouteView.java +++ b/src/main/java/kr/ac/pusan/pickle/publishing/dto/AdminRouteView.java @@ -1,21 +1,22 @@ package kr.ac.pusan.pickle.publishing.dto; import java.time.Instant; +import java.util.UUID; import kr.ac.pusan.pickle.publishing.DomainKind; import kr.ac.pusan.pickle.publishing.RouteStatus; import org.jspecify.annotations.Nullable; /** Contract schema {@code AdminRouteView} — a route with VM/workspace/org context. */ public record AdminRouteView( - Long id, - Long domainId, + UUID id, + UUID domainId, String fqdn, DomainKind domainKind, - Long vmId, + UUID vmId, String vmName, - Long workspaceId, + UUID workspaceId, String workspaceName, - Long orgId, + UUID orgId, String orgName, int targetPort, String protocol, diff --git a/src/main/java/kr/ac/pusan/pickle/publishing/dto/DomainDetailView.java b/src/main/java/kr/ac/pusan/pickle/publishing/dto/DomainDetailView.java index 4d1dde94..d61a5fb6 100644 --- a/src/main/java/kr/ac/pusan/pickle/publishing/dto/DomainDetailView.java +++ b/src/main/java/kr/ac/pusan/pickle/publishing/dto/DomainDetailView.java @@ -1,6 +1,7 @@ package kr.ac.pusan.pickle.publishing.dto; import java.time.Instant; +import java.util.UUID; import kr.ac.pusan.pickle.publishing.DomainKind; import kr.ac.pusan.pickle.publishing.DomainStatus; import org.jspecify.annotations.Nullable; @@ -11,8 +12,8 @@ * (null for platform subdomains). */ public record DomainDetailView( - Long id, - Long vmId, + UUID id, + UUID vmId, DomainKind kind, String fqdn, @Nullable String rootDomain, diff --git a/src/main/java/kr/ac/pusan/pickle/publishing/dto/DomainSummaryView.java b/src/main/java/kr/ac/pusan/pickle/publishing/dto/DomainSummaryView.java index 6eb911a4..6c09ad34 100644 --- a/src/main/java/kr/ac/pusan/pickle/publishing/dto/DomainSummaryView.java +++ b/src/main/java/kr/ac/pusan/pickle/publishing/dto/DomainSummaryView.java @@ -1,6 +1,7 @@ package kr.ac.pusan.pickle.publishing.dto; import java.time.Instant; +import java.util.UUID; import kr.ac.pusan.pickle.publishing.DomainKind; import kr.ac.pusan.pickle.publishing.DomainStatus; import org.jspecify.annotations.Nullable; @@ -13,8 +14,8 @@ * grace setting. */ public record DomainSummaryView( - Long id, - Long vmId, + UUID id, + UUID vmId, DomainKind kind, String fqdn, @Nullable String rootDomain, diff --git a/src/main/java/kr/ac/pusan/pickle/relay/AdminPortMappingController.java b/src/main/java/kr/ac/pusan/pickle/relay/AdminPortMappingController.java index cabc3231..887e67ed 100644 --- a/src/main/java/kr/ac/pusan/pickle/relay/AdminPortMappingController.java +++ b/src/main/java/kr/ac/pusan/pickle/relay/AdminPortMappingController.java @@ -8,6 +8,7 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; +import java.util.UUID; import kr.ac.pusan.pickle.auth.dto.MessageResponse; import kr.ac.pusan.pickle.common.web.PageResponse; import kr.ac.pusan.pickle.relay.dto.AdminPortMappingResponse; @@ -46,8 +47,8 @@ public AdminPortMappingController(AdminPortMappingService adminPortMappingServic @GetMapping public PageResponse listAdminPortMappings( - @RequestParam(required = false) Long relayId, - @RequestParam(required = false) Long vmId, + @RequestParam(required = false) UUID relayId, + @RequestParam(required = false) UUID vmId, @RequestParam(required = false) PortMappingStatus status, @RequestParam(defaultValue = "0") @Min(0) int page, @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) { @@ -57,7 +58,7 @@ public PageResponse listAdminPortMappings( /** 200 with the updated row (contract): convergence itself stays async. */ @PostMapping("/{mappingId}/suspend") public AdminPortMappingResponse suspendAdminPortMapping( - @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable long mappingId, + @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable UUID mappingId, @Valid @RequestBody SuspendPortMappingRequest request, HttpServletRequest httpRequest) { return adminPortMappingService.suspend(principal, mappingId, request.reason().strip(), @@ -66,7 +67,7 @@ public AdminPortMappingResponse suspendAdminPortMapping( @PostMapping("/{mappingId}/unsuspend") public AdminPortMappingResponse unsuspendAdminPortMapping( - @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable long mappingId, + @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable UUID mappingId, HttpServletRequest httpRequest) { return adminPortMappingService.unsuspend(principal, mappingId, clientIp(httpRequest)); } @@ -74,7 +75,7 @@ public AdminPortMappingResponse unsuspendAdminPortMapping( @DeleteMapping("/{mappingId}") @ResponseStatus(HttpStatus.ACCEPTED) public MessageResponse deleteAdminPortMapping( - @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable long mappingId, + @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable UUID mappingId, HttpServletRequest httpRequest) { return adminPortMappingService.delete(principal, mappingId, clientIp(httpRequest)); } @@ -89,7 +90,7 @@ public MessageResponse deleteAdminPortMapping( @io.swagger.v3.oas.annotations.parameters.RequestBody(content = @Content( schema = @Schema(implementation = UpdatePortMappingGuardsRequest.class))) public AdminPortMappingResponse updateAdminPortMappingGuards( - @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable long mappingId, + @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable UUID mappingId, @RequestBody tools.jackson.databind.JsonNode body, HttpServletRequest httpRequest) { return adminPortMappingService.updateGuards(principal, mappingId, body, clientIp(httpRequest)); diff --git a/src/main/java/kr/ac/pusan/pickle/relay/AdminPortMappingService.java b/src/main/java/kr/ac/pusan/pickle/relay/AdminPortMappingService.java index 83d49f63..91210893 100644 --- a/src/main/java/kr/ac/pusan/pickle/relay/AdminPortMappingService.java +++ b/src/main/java/kr/ac/pusan/pickle/relay/AdminPortMappingService.java @@ -5,6 +5,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.UUID; import java.util.function.Function; import java.util.stream.Collectors; import kr.ac.pusan.pickle.audit.AuditService; @@ -62,6 +63,7 @@ public class AdminPortMappingService { private final PortMappingRepository portMappingRepository; private final RelayRepository relayRepository; private final VmRepository vmRepository; + private final kr.ac.pusan.pickle.user.UserRepository userRepository; private final RelayGenerations relayGenerations; private final NotificationService notificationService; private final AuditService auditService; @@ -70,12 +72,14 @@ public class AdminPortMappingService { public AdminPortMappingService(PortMappingRepository portMappingRepository, RelayRepository relayRepository, VmRepository vmRepository, + kr.ac.pusan.pickle.user.UserRepository userRepository, RelayGenerations relayGenerations, NotificationService notificationService, AuditService auditService, VmEventRepository vmEventRepository, PortForwardingService portForwardingService) { this.portMappingRepository = portMappingRepository; this.relayRepository = relayRepository; this.vmRepository = vmRepository; + this.userRepository = userRepository; this.relayGenerations = relayGenerations; this.notificationService = notificationService; this.auditService = auditService; @@ -86,13 +90,16 @@ public AdminPortMappingService(PortMappingRepository portMappingRepository, // ── list ───────────────────────────────────────────────────────────────── @Transactional(readOnly = true) - public PageResponse list(Long relayId, Long vmId, + public PageResponse list(UUID publicRelayId, UUID publicVmId, PortMappingStatus status, int page, int size) { Specification spec = (root, query, cb) -> cb.conjunction(); - if (relayId != null) { + // An id no row has filters to nothing, as a non-matching number did. + if (publicRelayId != null) { + Long relayId = relayRepository.findByPublicId(publicRelayId).map(Relay::getId).orElse(-1L); spec = spec.and((root, query, cb) -> cb.equal(root.get("relayId"), relayId)); } - if (vmId != null) { + if (publicVmId != null) { + Long vmId = vmRepository.findByPublicId(publicVmId).map(Vm::getId).orElse(-1L); spec = spec.and((root, query, cb) -> cb.equal(root.get("vmId"), vmId)); } if (status != null) { @@ -111,21 +118,24 @@ public PageResponse list(Long relayId, Long vmId, relays.values().forEach(relay -> failedByRelay.put(relay.getId(), portForwardingService.failedIds(relay))); + Map userIds = userPublicIds(result.getContent()); List views = result.getContent().stream().map(mapping -> { Relay relay = relays.get(mapping.getRelayId()); Vm vm = vms.get(mapping.getVmId()); - return new AdminPortMappingResponse(mapping.getId(), mapping.getRelayId(), - relay != null ? relay.getName() : "", mapping.getVmId(), + return new AdminPortMappingResponse(mapping.getPublicId(), + relay != null ? relay.getPublicId() : null, + relay != null ? relay.getName() : "", + vm != null ? vm.getPublicId() : null, vm != null ? vm.getName() : null, mapping.getProto(), mapping.getPublicPort(), mapping.getTargetPort(), mapping.getStatus(), - mapping.getSuspendedReason(), mapping.getSuspendedBy(), + mapping.getSuspendedReason(), userIds.get(mapping.getSuspendedBy()), relay == null ? PortForwardApplyState.PENDING : PortForwardingService.applyState(mapping, relay.getAppliedGeneration(), failedByRelay.getOrDefault(relay.getId(), Set.of())), mapping.getCtMax(), mapping.getNewConnRate(), mapping.getNewConnBurst(), mapping.getPerSourceRate(), mapping.getPerSourceBurst(), - mapping.getCreatedBy(), mapping.getCreatedAt()); + userIds.get(mapping.getCreatedBy()), mapping.getCreatedAt()); }).toList(); return PageResponse.of(views, result); } @@ -133,7 +143,7 @@ public PageResponse list(Long relayId, Long vmId, // ── suspend / unsuspend ────────────────────────────────────────────────── @Transactional - public AdminPortMappingResponse suspend(AuthenticatedUser actor, long mappingId, + public AdminPortMappingResponse suspend(AuthenticatedUser actor, UUID mappingId, String reason, String ip) { PortMapping mapping = requireMapping(mappingId); if (mapping.getStatus() != PortMappingStatus.ACTIVE) { @@ -149,20 +159,20 @@ public AdminPortMappingResponse suspend(AuthenticatedUser actor, long mappingId, notificationService.publish( notificationService.vmResponsibleIds(vm), NotificationEvent.PORT_MAPPING_SUSPENDED, - Map.of("vmId", vm.getId(), "vmName", vm.getName(), + Map.of("vmId", vm.getPublicId(), "vmName", vm.getName(), "proto", mapping.getProto().name(), "publicPort", mapping.getPublicPort(), "reason", reason), null); } auditService.recordAfterCommit(actor.id(), actor.role().name(), - AuditService.PORT_MAPPING_SUSPEND, "port_mapping", mappingId, + AuditService.PORT_MAPPING_SUSPEND, "port_mapping", mapping.getPublicId(), Map.of("auto", false, "relayId", mapping.getRelayId(), "vmId", mapping.getVmId(), "reason", reason), ip); return toResponse(mapping); } @Transactional - public AdminPortMappingResponse unsuspend(AuthenticatedUser actor, long mappingId, + public AdminPortMappingResponse unsuspend(AuthenticatedUser actor, UUID mappingId, String ip) { PortMapping mapping = requireMapping(mappingId); if (mapping.getStatus() != PortMappingStatus.SUSPENDED) { @@ -174,7 +184,7 @@ public AdminPortMappingResponse unsuspend(AuthenticatedUser actor, long mappingI mapping.setSuspendedBy(null); mapping.setLastChangeGeneration(generation); auditService.recordAfterCommit(actor.id(), actor.role().name(), - AuditService.PORT_MAPPING_UNSUSPEND, "port_mapping", mappingId, + AuditService.PORT_MAPPING_UNSUSPEND, "port_mapping", mapping.getPublicId(), Map.of("relayId", mapping.getRelayId(), "vmId", mapping.getVmId()), ip); return toResponse(mapping); } @@ -182,7 +192,7 @@ public AdminPortMappingResponse unsuspend(AuthenticatedUser actor, long mappingI // ── delete ─────────────────────────────────────────────────────────────── @Transactional - public MessageResponse delete(AuthenticatedUser actor, long mappingId, String ip) { + public MessageResponse delete(AuthenticatedUser actor, UUID mappingId, String ip) { PortMapping mapping = requireMapping(mappingId); relayGenerations.bump(mapping.getRelayId()); portMappingRepository.delete(mapping); @@ -195,13 +205,13 @@ public MessageResponse delete(AuthenticatedUser actor, long mappingId, String ip notificationService.publish( notificationService.vmResponsibleIds(vm), NotificationEvent.PORT_MAPPING_DELETED, - Map.of("vmId", vm.getId(), "vmName", vm.getName(), + Map.of("vmId", vm.getPublicId(), "vmName", vm.getName(), "proto", mapping.getProto().name(), "publicPort", mapping.getPublicPort()), null); } auditService.recordAfterCommit(actor.id(), actor.role().name(), - AuditService.PORT_MAPPING_DELETE, "port_mapping", mappingId, + AuditService.PORT_MAPPING_DELETE, "port_mapping", mapping.getPublicId(), Map.of("relayId", mapping.getRelayId(), "vmId", mapping.getVmId(), "proto", mapping.getProto().name(), "publicPort", mapping.getPublicPort()), ip); @@ -217,7 +227,7 @@ public MessageResponse delete(AuthenticatedUser actor, long mappingId, String ip * the relay generation. */ @Transactional - public AdminPortMappingResponse updateGuards(AuthenticatedUser actor, long mappingId, + public AdminPortMappingResponse updateGuards(AuthenticatedUser actor, UUID mappingId, JsonNode body, String ip) { PortMapping mapping = requireMapping(mappingId); Map changes = new LinkedHashMap<>(); @@ -276,7 +286,7 @@ public AdminPortMappingResponse updateGuards(AuthenticatedUser actor, long mappi long generation = relayGenerations.bump(mapping.getRelayId()); mapping.setLastChangeGeneration(generation); auditService.recordAfterCommit(actor.id(), actor.role().name(), - AuditService.PORT_MAPPING_GUARDS_UPDATE, "port_mapping", mappingId, + AuditService.PORT_MAPPING_GUARDS_UPDATE, "port_mapping", mapping.getPublicId(), Map.of("relayId", mapping.getRelayId(), "vmId", mapping.getVmId(), "changes", changes), ip); return toResponse(mapping); @@ -288,19 +298,33 @@ public AdminPortMappingResponse updateGuards(AuthenticatedUser actor, long mappi private AdminPortMappingResponse toResponse(PortMapping mapping) { Relay relay = relayRepository.findById(mapping.getRelayId()).orElseThrow(); Vm vm = vmRepository.findById(mapping.getVmId()).orElse(null); - return new AdminPortMappingResponse(mapping.getId(), mapping.getRelayId(), relay.getName(), - mapping.getVmId(), vm != null ? vm.getName() : null, mapping.getProto(), + Map userIds = userPublicIds(List.of(mapping)); + return new AdminPortMappingResponse(mapping.getPublicId(), relay.getPublicId(), relay.getName(), + vm != null ? vm.getPublicId() : null, vm != null ? vm.getName() : null, + mapping.getProto(), mapping.getPublicPort(), mapping.getTargetPort(), mapping.getStatus(), - mapping.getSuspendedReason(), mapping.getSuspendedBy(), + mapping.getSuspendedReason(), userIds.get(mapping.getSuspendedBy()), PortForwardingService.applyState(mapping, relay.getAppliedGeneration(), portForwardingService.failedIds(relay)), mapping.getCtMax(), mapping.getNewConnRate(), mapping.getNewConnBurst(), - mapping.getPerSourceRate(), mapping.getPerSourceBurst(), mapping.getCreatedBy(), - mapping.getCreatedAt()); + mapping.getPerSourceRate(), mapping.getPerSourceBurst(), + userIds.get(mapping.getCreatedBy()), mapping.getCreatedAt()); } - private PortMapping requireMapping(long mappingId) { - return portMappingRepository.findById(mappingId) + /** Batch account join for {@code createdBy}/{@code suspendedBy}. */ + private Map userPublicIds(List mappings) { + List ids = java.util.stream.Stream.concat( + mappings.stream().map(PortMapping::getCreatedBy), + mappings.stream().map(PortMapping::getSuspendedBy)) + .filter(java.util.Objects::nonNull).distinct().toList(); + return ids.isEmpty() ? Map.of() + : userRepository.findAllById(ids).stream() + .collect(Collectors.toMap(kr.ac.pusan.pickle.user.User::getId, + kr.ac.pusan.pickle.user.User::getPublicId)); + } + + private PortMapping requireMapping(UUID mappingId) { + return portMappingRepository.findByPublicId(mappingId) .orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, ErrorCodes.RESOURCE_NOT_FOUND, "리소스를 찾을 수 없습니다", "해당 포트 매핑이 존재하지 않습니다.")); diff --git a/src/main/java/kr/ac/pusan/pickle/relay/AdminRelayController.java b/src/main/java/kr/ac/pusan/pickle/relay/AdminRelayController.java index 9037d147..d758af23 100644 --- a/src/main/java/kr/ac/pusan/pickle/relay/AdminRelayController.java +++ b/src/main/java/kr/ac/pusan/pickle/relay/AdminRelayController.java @@ -4,6 +4,7 @@ import jakarta.servlet.http.HttpServletRequest; import java.util.List; +import java.util.UUID; import kr.ac.pusan.pickle.relay.dto.AdminRelayView; import kr.ac.pusan.pickle.relay.dto.RelayTokenResponse; import kr.ac.pusan.pickle.security.AuthenticatedUser; @@ -41,7 +42,7 @@ public List listAdminRelays() { @PreAuthorize("hasRole('SYS_ADMIN')") @RequireReauth public RelayTokenResponse issueAdminRelayToken( - @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable long relayId, + @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable UUID relayId, HttpServletRequest httpRequest) { return adminRelayService.issueToken(principal, relayId, clientIp(httpRequest)); } diff --git a/src/main/java/kr/ac/pusan/pickle/relay/AdminRelayService.java b/src/main/java/kr/ac/pusan/pickle/relay/AdminRelayService.java index f03cfe0e..08a7f148 100644 --- a/src/main/java/kr/ac/pusan/pickle/relay/AdminRelayService.java +++ b/src/main/java/kr/ac/pusan/pickle/relay/AdminRelayService.java @@ -4,6 +4,7 @@ import java.time.Instant; import java.util.List; import java.util.Map; +import java.util.UUID; import kr.ac.pusan.pickle.audit.AuditService; import kr.ac.pusan.pickle.common.error.ApiException; import kr.ac.pusan.pickle.common.error.ErrorCodes; @@ -47,7 +48,7 @@ public List list() { int usagePercent = (int) (mappingCount * 100 / relay.bandSize()); boolean contactLost = relay.getLastContactAt() != null && relay.getLastContactAt().isBefore(lostBefore); - return new AdminRelayView(relay.getId(), relay.getName(), relay.getPublicHost(), + return new AdminRelayView(relay.getPublicId(), relay.getName(), relay.getPublicHost(), relay.getPortBandStart(), relay.getPortBandEnd(), relay.isEnabled(), relay.getTokenHash() != null, relay.getMappingGeneration(), relay.getAppliedGeneration(), relay.getLastContactAt(), contactLost, @@ -61,16 +62,16 @@ public List list() { * never logged or audited. */ @Transactional - public RelayTokenResponse issueToken(AuthenticatedUser actor, long relayId, String ip) { - Relay relay = relayRepository.findById(relayId) + public RelayTokenResponse issueToken(AuthenticatedUser actor, UUID relayId, String ip) { + Relay relay = relayRepository.findByPublicId(relayId) .orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, ErrorCodes.RESOURCE_NOT_FOUND, "리소스를 찾을 수 없습니다", "해당 릴레이가 존재하지 않습니다.")); String token = RelayTokens.newToken(); relay.setTokenHash(RelayTokens.sha256Hex(token)); auditService.recordAfterCommit(actor.id(), actor.role().name(), - AuditService.RELAY_TOKEN_ISSUE, "relay", relayId, + AuditService.RELAY_TOKEN_ISSUE, "relay", relay.getPublicId(), Map.of("relayName", relay.getName(), "rotated", true), ip); - return new RelayTokenResponse(relayId, token); + return new RelayTokenResponse(relay.getPublicId(), token); } } diff --git a/src/main/java/kr/ac/pusan/pickle/relay/PortForwardingController.java b/src/main/java/kr/ac/pusan/pickle/relay/PortForwardingController.java index 37d53afa..36cc92f3 100644 --- a/src/main/java/kr/ac/pusan/pickle/relay/PortForwardingController.java +++ b/src/main/java/kr/ac/pusan/pickle/relay/PortForwardingController.java @@ -5,6 +5,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import java.util.List; +import java.util.UUID; import kr.ac.pusan.pickle.auth.dto.MessageResponse; import kr.ac.pusan.pickle.relay.dto.CreatePortForwardingRequest; import kr.ac.pusan.pickle.relay.dto.PortForwardingView; @@ -37,14 +38,14 @@ public PortForwardingController(PortForwardingService portForwardingService) { @GetMapping public List listVmPortForwardings( - @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable long vmId) { + @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable UUID vmId) { return portForwardingService.list(principal, vmId); } @PostMapping @ResponseStatus(HttpStatus.CREATED) public PortForwardingView createVmPortForwarding( - @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable long vmId, + @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable UUID vmId, @Valid @RequestBody CreatePortForwardingRequest request, HttpServletRequest httpRequest) { return portForwardingService.create(principal, vmId, request, clientIp(httpRequest)); @@ -53,8 +54,8 @@ public PortForwardingView createVmPortForwarding( @DeleteMapping("/{portForwardingId}") @ResponseStatus(HttpStatus.ACCEPTED) public MessageResponse deleteVmPortForwarding( - @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable long vmId, - @PathVariable long portForwardingId, HttpServletRequest httpRequest) { + @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable UUID vmId, + @PathVariable UUID portForwardingId, HttpServletRequest httpRequest) { return portForwardingService.delete(principal, vmId, portForwardingId, clientIp(httpRequest)); } diff --git a/src/main/java/kr/ac/pusan/pickle/relay/PortForwardingService.java b/src/main/java/kr/ac/pusan/pickle/relay/PortForwardingService.java index 5fae27ca..fbfe9303 100644 --- a/src/main/java/kr/ac/pusan/pickle/relay/PortForwardingService.java +++ b/src/main/java/kr/ac/pusan/pickle/relay/PortForwardingService.java @@ -5,6 +5,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.UUID; import kr.ac.pusan.pickle.access.ResourceRole; import kr.ac.pusan.pickle.access.VmAccessService; import kr.ac.pusan.pickle.audit.AuditService; @@ -101,18 +102,19 @@ public PortForwardingService(VmRepository vmRepository, // ── list ───────────────────────────────────────────────────────────────── @Transactional(readOnly = true) - public List list(AuthenticatedUser actor, long vmId) { - requireVmMember(actor, vmId); - List mappings = portMappingRepository.findByVmIdOrderByIdAsc(vmId); + public List list(AuthenticatedUser actor, UUID publicVmId) { + Vm vm = requireVmMember(actor, publicVmId); + List mappings = portMappingRepository.findByVmIdOrderByIdAsc(vm.getId()); return mappings.stream().map(this::toView).toList(); } // ── create ─────────────────────────────────────────────────────────────── @Transactional - public PortForwardingView create(AuthenticatedUser actor, long vmId, + public PortForwardingView create(AuthenticatedUser actor, UUID publicVmId, CreatePortForwardingRequest request, String ip) { - Vm vm = requireVmOwnerOrEditor(actor, vmId); + Vm vm = requireVmOwnerOrEditor(actor, publicVmId); + long vmId = vm.getId(); if (!settingsService.bool(SettingsService.PORT_FORWARDING_ENABLED, false)) { throw new ApiException(HttpStatus.CONFLICT, ErrorCodes.PORT_FORWARDING_DISABLED, "포트 포워딩이 비활성화되어 있습니다", @@ -147,7 +149,7 @@ public PortForwardingView create(AuthenticatedUser actor, long vmId, vmEventRepository.save(new VmEvent(vmId, VmEventType.PORT_FORWARD_CREATE, actor.id(), request.proto() + " 공개 포트 할당 → 대상 포트 " + request.targetPort())); auditService.recordAfterCommit(actor.id(), actor.role().name(), - AuditService.VM_PORT_FORWARD_CREATE, "vm", vmId, + AuditService.VM_PORT_FORWARD_CREATE, "vm", vm.getPublicId(), Map.of("mappingId", mappingId, "relayId", relay.getId(), "proto", request.proto().name(), "targetPort", request.targetPort()), ip); return toView(portMappingRepository.findById(mappingId).orElseThrow()); @@ -156,17 +158,19 @@ public PortForwardingView create(AuthenticatedUser actor, long vmId, // ── delete ─────────────────────────────────────────────────────────────── @Transactional - public MessageResponse delete(AuthenticatedUser actor, long vmId, long portForwardingId, - String ip) { - requireVmOwnerOrEditor(actor, vmId); - PortMapping mapping = portMappingRepository.findByIdAndVmId(portForwardingId, vmId) + public MessageResponse delete(AuthenticatedUser actor, UUID publicVmId, + UUID portForwardingId, String ip) { + Vm vm = requireVmOwnerOrEditor(actor, publicVmId); + long vmId = vm.getId(); + PortMapping mapping = portMappingRepository.findByPublicId(portForwardingId) + .filter(row -> row.getVmId() == vmId) .orElseThrow(PortForwardingService::mappingNotFound); relayGenerations.bump(mapping.getRelayId()); portMappingRepository.delete(mapping); vmEventRepository.save(new VmEvent(vmId, VmEventType.PORT_FORWARD_DELETE, actor.id(), mapping.getProto() + " " + mapping.getPublicPort() + " 공개 해제")); auditService.recordAfterCommit(actor.id(), actor.role().name(), - AuditService.VM_PORT_FORWARD_DELETE, "vm", vmId, + AuditService.VM_PORT_FORWARD_DELETE, "vm", vm.getPublicId(), Map.of("mappingId", mapping.getId(), "relayId", mapping.getRelayId(), "proto", mapping.getProto().name(), "publicPort", mapping.getPublicPort()), ip); @@ -247,7 +251,7 @@ private void alertOnBandUsage(Relay relay) { private PortForwardingView toView(PortMapping mapping) { Relay relay = relayRepository.findById(mapping.getRelayId()).orElseThrow(); - return new PortForwardingView(mapping.getId(), mapping.getProto(), + return new PortForwardingView(mapping.getPublicId(), mapping.getProto(), mapping.getPublicPort(), relay.getPublicHost(), mapping.getTargetPort(), mapping.getStatus(), applyState(mapping, relay.getAppliedGeneration(), failedIds(relay)), @@ -292,11 +296,11 @@ Set failedIds(Relay relay) { // ── authorization (publishing pattern: 404 mask, 403 for members) ─────── - private Vm requireVmMember(AuthenticatedUser actor, long vmId) { + private Vm requireVmMember(AuthenticatedUser actor, UUID vmId) { return vmAccessService.of(actor, vmId).requireVisible(); } - private Vm requireVmOwnerOrEditor(AuthenticatedUser actor, long vmId) { + private Vm requireVmOwnerOrEditor(AuthenticatedUser actor, UUID vmId) { return vmAccessService.of(actor, vmId).requireAtLeast(ResourceRole.EDITOR, "포트 포워딩을 관리할 권한이 없습니다", "이 VM의 소유자 또는 편집자만 포트포워딩을 설정할 수 있습니다."); diff --git a/src/main/java/kr/ac/pusan/pickle/relay/PortMapping.java b/src/main/java/kr/ac/pusan/pickle/relay/PortMapping.java index 0c3ffd84..a1f0f390 100644 --- a/src/main/java/kr/ac/pusan/pickle/relay/PortMapping.java +++ b/src/main/java/kr/ac/pusan/pickle/relay/PortMapping.java @@ -9,8 +9,11 @@ import jakarta.persistence.Id; import jakarta.persistence.Table; import java.time.Instant; +import java.util.UUID; import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.annotations.UpdateTimestamp; +import org.hibernate.type.SqlTypes; /** * One desired relay DNAT rule (port_mappings): relay {@code publicPort} → @@ -34,6 +37,14 @@ public class PortMapping { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + /** + * The identifier this row wears outside the API boundary. Internal joins, + * sorts and foreign keys keep using {@link #id}. + */ + @JdbcTypeCode(SqlTypes.UUID) + @Column(name = "public_id", nullable = false, updatable = false, unique = true) + private UUID publicId = UUID.randomUUID(); + @Column(name = "relay_id", nullable = false) private Long relayId; @@ -97,6 +108,10 @@ public Long getId() { return id; } + public UUID getPublicId() { + return publicId; + } + public Long getRelayId() { return relayId; } diff --git a/src/main/java/kr/ac/pusan/pickle/relay/PortMappingRepository.java b/src/main/java/kr/ac/pusan/pickle/relay/PortMappingRepository.java index 26acd702..616bbe22 100644 --- a/src/main/java/kr/ac/pusan/pickle/relay/PortMappingRepository.java +++ b/src/main/java/kr/ac/pusan/pickle/relay/PortMappingRepository.java @@ -2,12 +2,16 @@ import java.util.List; import java.util.Optional; +import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; public interface PortMappingRepository extends JpaRepository, JpaSpecificationExecutor { + /** Resolution of the identifier this row wears outside the API boundary. */ + Optional findByPublicId(UUID publicId); + List findByVmIdOrderByIdAsc(long vmId); Optional findByIdAndVmId(long id, long vmId); diff --git a/src/main/java/kr/ac/pusan/pickle/relay/Relay.java b/src/main/java/kr/ac/pusan/pickle/relay/Relay.java index ff3b3f6a..de227898 100644 --- a/src/main/java/kr/ac/pusan/pickle/relay/Relay.java +++ b/src/main/java/kr/ac/pusan/pickle/relay/Relay.java @@ -7,6 +7,7 @@ import jakarta.persistence.Id; import jakarta.persistence.Table; import java.time.Instant; +import java.util.UUID; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.annotations.UpdateTimestamp; @@ -34,6 +35,14 @@ public class Relay { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + /** + * The identifier this row wears outside the API boundary. Internal joins, + * sorts and foreign keys keep using {@link #id}. + */ + @JdbcTypeCode(SqlTypes.UUID) + @Column(name = "public_id", nullable = false, updatable = false, unique = true) + private UUID publicId = UUID.randomUUID(); + @Column(nullable = false) private String name; @@ -91,6 +100,10 @@ public Long getId() { return id; } + public UUID getPublicId() { + return publicId; + } + public String getName() { return name; } diff --git a/src/main/java/kr/ac/pusan/pickle/relay/RelayRepository.java b/src/main/java/kr/ac/pusan/pickle/relay/RelayRepository.java index ae14f9e0..7a232a55 100644 --- a/src/main/java/kr/ac/pusan/pickle/relay/RelayRepository.java +++ b/src/main/java/kr/ac/pusan/pickle/relay/RelayRepository.java @@ -2,10 +2,14 @@ import java.util.List; import java.util.Optional; +import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; public interface RelayRepository extends JpaRepository { + /** Resolution of the identifier this row wears outside the API boundary. */ + Optional findByPublicId(UUID publicId); + /** Allocation target: the lowest-id enabled relay (single relay today). */ Optional findFirstByEnabledTrueOrderByIdAsc(); diff --git a/src/main/java/kr/ac/pusan/pickle/relay/RelaySyncService.java b/src/main/java/kr/ac/pusan/pickle/relay/RelaySyncService.java index 5b07421e..fd8bafbe 100644 --- a/src/main/java/kr/ac/pusan/pickle/relay/RelaySyncService.java +++ b/src/main/java/kr/ac/pusan/pickle/relay/RelaySyncService.java @@ -128,7 +128,7 @@ public RelaySyncResponse sync(long relayId, RelaySyncRequest request) { // Direct record (not after-commit): a security signal, keep it even // if something later in this tx were to fail. auditService.record(null, AuditService.ACTOR_ROLE_RELAY, - AuditService.RELAY_SYNC_VIOLATION, "relay", relayId, + AuditService.RELAY_SYNC_VIOLATION, "relay", relayPublicId(relayId), Map.of("reported", reported, "storedApplied", state.applied(), "currentGeneration", state.current(), "agentVersion", agentVersion == null ? "" : agentVersion), null); @@ -198,7 +198,7 @@ private void accumulateCounters(long relayId, // baseline stays put, and the event is audited. Bounds the // bigint totals against a lying or corrupted agent. auditService.record(null, AuditService.ACTOR_ROLE_RELAY, - AuditService.RELAY_SYNC_VIOLATION, "relay", relayId, + AuditService.RELAY_SYNC_VIOLATION, "relay", relayPublicId(relayId), Map.of("kind", "counter_sanity", "mappingId", mappingId, "maxReported", String.valueOf(raw.max())), null); log.warn("relay {} reported an insane counter for mapping {} (max {})", @@ -273,25 +273,39 @@ private void autoSuspend(long relayId, long mappingId, long connsPerMin, long mb if (suspended != 1) { return; // already suspended (e.g. duplicated report row) } + // v.public_id, not v.id: the notification's link path is built from + // this map by string concatenation, so a numeric id here renders a + // console link that resolves to nothing. Map context = jdbcTemplate.queryForObject(""" - select m.vm_id, v.name as vm_name, m.proto, m.public_port + select v.public_id as vm_public_id, v.name as vm_name, m.proto, m.public_port, + m.public_id as mapping_public_id from port_mappings m join vms v on v.id = m.vm_id where m.id = ? """, (rs, rowNum) -> Map.of( - "vmId", rs.getLong("vm_id"), "vmName", rs.getString("vm_name"), - "proto", rs.getString("proto"), "publicPort", rs.getInt("public_port")), + "vmId", rs.getObject("vm_public_id", java.util.UUID.class), + "vmName", rs.getString("vm_name"), + "proto", rs.getString("proto"), "publicPort", rs.getInt("public_port"), + "mappingPublicId", rs.getObject("mapping_public_id", java.util.UUID.class)), mappingId); + java.util.UUID mappingPublicId = (java.util.UUID) context.get("mappingPublicId"); Map args = new LinkedHashMap<>(context); + args.remove("mappingPublicId"); args.put("reason", reason); notificationService.publish(notificationService.sysAdminIds(), NotificationEvent.PORT_MAPPING_SUSPENDED, args, "pm_auto_suspend:" + mappingId); auditService.recordAfterCommit(null, AuditService.ACTOR_ROLE_RELAY, AuditService.PORT_MAPPING_SUSPEND, - "port_mapping", mappingId, Map.of("auto", true, "relayId", relayId, + "port_mapping", mappingPublicId, Map.of("auto", true, "relayId", relayId, "connsPerMin", connsPerMin, "mbytesPerMin", mbytesPerMin, "connsLimit", connsLimit, "mbytesLimit", mbytesLimit), null); log.warn("port mapping {} auto-suspended (conns/min {} vs {}, MB/min {} vs {})", mappingId, connsPerMin, connsLimit, mbytesPerMin, mbytesLimit); } + /** The relay's public identifier, for the audit trail's target column. */ + private java.util.UUID relayPublicId(long relayId) { + return jdbcTemplate.queryForObject("select public_id from relays where id = ?", + java.util.UUID.class, relayId); + } + // ── snapshot ───────────────────────────────────────────────────────────── private RelaySyncResponse readSnapshot(long relayId, long validatedApplied, diff --git a/src/main/java/kr/ac/pusan/pickle/relay/dto/AdminPortMappingResponse.java b/src/main/java/kr/ac/pusan/pickle/relay/dto/AdminPortMappingResponse.java index 8798a17a..cefc9c7d 100644 --- a/src/main/java/kr/ac/pusan/pickle/relay/dto/AdminPortMappingResponse.java +++ b/src/main/java/kr/ac/pusan/pickle/relay/dto/AdminPortMappingResponse.java @@ -2,6 +2,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import java.time.Instant; +import java.util.UUID; import kr.ac.pusan.pickle.relay.PortForwardApplyState; import kr.ac.pusan.pickle.relay.PortMappingProto; import kr.ac.pusan.pickle.relay.PortMappingStatus; @@ -9,10 +10,10 @@ /** Contract schema {@code AdminPortMappingResponse} — one mapping with context. */ public record AdminPortMappingResponse( - Long id, - Long relayId, + UUID id, + UUID relayId, String relayName, - Long vmId, + UUID vmId, @Nullable String vmName, PortMappingProto proto, int publicPort, @@ -23,7 +24,7 @@ public record AdminPortMappingResponse( String suspendedReason, @Nullable @Schema(description = "정지한 관리자 id (자동 정지면 null)") - Long suspendedBy, + UUID suspendedBy, PortForwardApplyState applyState, @Nullable @Schema(description = "동시 연결 상한 오버라이드 (null = 에이전트 기본, 0 = 해제)") @@ -40,6 +41,6 @@ public record AdminPortMappingResponse( @Nullable @Schema(description = "출발지별 버스트 오버라이드") Integer perSourceBurst, - Long createdBy, + @Nullable UUID createdBy, Instant createdAt) { } diff --git a/src/main/java/kr/ac/pusan/pickle/relay/dto/AdminRelayView.java b/src/main/java/kr/ac/pusan/pickle/relay/dto/AdminRelayView.java index af2a061a..22a79d00 100644 --- a/src/main/java/kr/ac/pusan/pickle/relay/dto/AdminRelayView.java +++ b/src/main/java/kr/ac/pusan/pickle/relay/dto/AdminRelayView.java @@ -2,6 +2,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import java.time.Instant; +import java.util.UUID; import org.jspecify.annotations.Nullable; /** @@ -10,7 +11,7 @@ * are claims reported by the relay agent, not measurements. */ public record AdminRelayView( - Long id, + UUID id, String name, @Nullable @Schema(description = "사용자 접속용 공개 호스트 (설정 전이면 null)") diff --git a/src/main/java/kr/ac/pusan/pickle/relay/dto/PortForwardingView.java b/src/main/java/kr/ac/pusan/pickle/relay/dto/PortForwardingView.java index 2b3959d6..f2b6b6d3 100644 --- a/src/main/java/kr/ac/pusan/pickle/relay/dto/PortForwardingView.java +++ b/src/main/java/kr/ac/pusan/pickle/relay/dto/PortForwardingView.java @@ -2,6 +2,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import java.time.Instant; +import java.util.UUID; import kr.ac.pusan.pickle.relay.PortForwardApplyState; import kr.ac.pusan.pickle.relay.PortMappingProto; import kr.ac.pusan.pickle.relay.PortMappingStatus; @@ -9,7 +10,7 @@ /** Contract schema {@code PortForwardingView} — one VM port forwarding. */ public record PortForwardingView( - Long id, + UUID id, @Schema(description = "프로토콜 (tcp | udp)") PortMappingProto proto, @Schema(description = "릴레이에서 열린 공개 포트") diff --git a/src/main/java/kr/ac/pusan/pickle/relay/dto/RelayTokenResponse.java b/src/main/java/kr/ac/pusan/pickle/relay/dto/RelayTokenResponse.java index 6e44a29c..db2432bd 100644 --- a/src/main/java/kr/ac/pusan/pickle/relay/dto/RelayTokenResponse.java +++ b/src/main/java/kr/ac/pusan/pickle/relay/dto/RelayTokenResponse.java @@ -1,6 +1,7 @@ package kr.ac.pusan.pickle.relay.dto; import io.swagger.v3.oas.annotations.media.Schema; +import java.util.UUID; /** * Contract op {@code issueAdminRelayToken} response. The plaintext token is @@ -8,7 +9,7 @@ * again (re-issue replaces it). */ public record RelayTokenResponse( - Long relayId, + UUID relayId, @Schema(description = "새 동기화 토큰(64자 hex). 이 응답에서만 확인 가능하며 저장되지 않습니다") String token) { } diff --git a/src/main/java/kr/ac/pusan/pickle/request/Request.java b/src/main/java/kr/ac/pusan/pickle/request/Request.java index 04e56577..cffe2971 100644 --- a/src/main/java/kr/ac/pusan/pickle/request/Request.java +++ b/src/main/java/kr/ac/pusan/pickle/request/Request.java @@ -10,6 +10,7 @@ import jakarta.persistence.Table; import java.time.Instant; import java.time.LocalDate; +import java.util.UUID; import kr.ac.pusan.pickle.access.ResourceType; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.JdbcTypeCode; @@ -31,6 +32,14 @@ public class Request { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + /** + * The identifier this row wears outside the API boundary. Internal joins, + * sorts and foreign keys keep using {@link #id}. + */ + @JdbcTypeCode(SqlTypes.UUID) + @Column(name = "public_id", nullable = false, updatable = false, unique = true) + private UUID publicId = UUID.randomUUID(); + @Enumerated(EnumType.STRING) @JdbcTypeCode(SqlTypes.NAMED_ENUM) @Column(name = "resource_type", nullable = false, columnDefinition = "resource_type") @@ -99,6 +108,10 @@ public Long getId() { return id; } + public UUID getPublicId() { + return publicId; + } + public ResourceType getResourceType() { return resourceType; } diff --git a/src/main/java/kr/ac/pusan/pickle/request/RequestAssembler.java b/src/main/java/kr/ac/pusan/pickle/request/RequestAssembler.java index e5867bb2..6a0f48c4 100644 --- a/src/main/java/kr/ac/pusan/pickle/request/RequestAssembler.java +++ b/src/main/java/kr/ac/pusan/pickle/request/RequestAssembler.java @@ -7,7 +7,14 @@ import java.util.function.Function; import java.util.stream.Collectors; import java.util.stream.Stream; +import java.util.UUID; import kr.ac.pusan.pickle.access.ResourceType; +import kr.ac.pusan.pickle.inventory.Node; +import kr.ac.pusan.pickle.inventory.NodeRepository; +import kr.ac.pusan.pickle.inventory.OsImage; +import kr.ac.pusan.pickle.inventory.OsImageRepository; +import kr.ac.pusan.pickle.inventory.VmFlavor; +import kr.ac.pusan.pickle.inventory.VmFlavorRepository; import kr.ac.pusan.pickle.workspace.Workspace; import kr.ac.pusan.pickle.workspace.WorkspaceRepository; import kr.ac.pusan.pickle.orgs.Org; @@ -34,15 +41,23 @@ public class RequestAssembler { private final WorkspaceRepository workspaceRepository; private final OrgRepository orgRepository; private final UserRepository userRepository; + private final OsImageRepository osImageRepository; + private final VmFlavorRepository vmFlavorRepository; + private final NodeRepository nodeRepository; public RequestAssembler(RequestReviewRepository reviewRepository, VmRequestDetailRepository vmDetailRepository, WorkspaceRepository workspaceRepository, - OrgRepository orgRepository, UserRepository userRepository) { + OrgRepository orgRepository, UserRepository userRepository, + OsImageRepository osImageRepository, VmFlavorRepository vmFlavorRepository, + NodeRepository nodeRepository) { this.reviewRepository = reviewRepository; this.vmDetailRepository = vmDetailRepository; this.workspaceRepository = workspaceRepository; this.orgRepository = orgRepository; this.userRepository = userRepository; + this.osImageRepository = osImageRepository; + this.vmFlavorRepository = vmFlavorRepository; + this.nodeRepository = nodeRepository; } public RequestDetailResponse toDetail(Request request) { @@ -71,6 +86,19 @@ public List toDetails(List requests) { .findByRequestIdIn(idsOfType(requests, ResourceType.VM)).stream() .collect(Collectors.toMap(VmRequestDetail::getRequestId, Function.identity())); + // The per-type spec reports its catalog references by public id, so the + // rows behind them are batched here beside the display-name joins. + Map imageIds = publicIds(osImageRepository.findAllById(ids(Stream.concat( + vmDetails.values().stream().map(VmRequestDetail::getImageId), + vmDetails.values().stream().map(VmRequestDetail::getGrantedImageId)))), + OsImage::getId, OsImage::getPublicId); + Map flavorIds = publicIds(vmFlavorRepository.findAllById(ids( + vmDetails.values().stream().map(VmRequestDetail::getFlavorId))), + VmFlavor::getId, VmFlavor::getPublicId); + Map nodeIds = publicIds(nodeRepository.findAllById(ids( + vmDetails.values().stream().map(VmRequestDetail::getNodeId))), + Node::getId, Node::getPublicId); + List details = new ArrayList<>(requests.size()); for (Request request : requests) { RequestReview review = reviews.get(request.getId()); @@ -79,18 +107,24 @@ public List toDetails(List requests) { User requester = users.get(request.getRequesterId()); VmRequestDetail vmDetail = vmDetails.get(request.getId()); details.add(new RequestDetailResponse( - request.getId(), + request.getPublicId(), request.getResourceType(), - request.getWorkspaceId(), workspace != null ? workspace.getName() : null, - request.getOrgId(), org != null ? org.getName() : null, - request.getRequesterId(), requester != null ? requester.getName() : "탈퇴 회원", + workspace != null ? workspace.getPublicId() : null, + workspace != null ? workspace.getName() : null, + org != null ? org.getPublicId() : null, org != null ? org.getName() : null, + requester != null ? requester.getPublicId() : null, + requester != null ? requester.getName() : "탈퇴 회원", request.getPurpose(), request.getCourseOrProject(), request.getExtraNote(), request.getReqStartDate(), request.getReqEndDate(), request.getDisplayName(), request.getStatus(), review != null ? RequestReviewResponse.from(review, users.get(review.getReviewerId())) : null, - vmDetail != null ? VmRequestSpecResponse.from(vmDetail) : null, + vmDetail != null ? VmRequestSpecResponse.from(vmDetail, + imageIds.get(vmDetail.getImageId()), + flavorIds.get(vmDetail.getFlavorId()), + imageIds.get(vmDetail.getGrantedImageId()), + nodeIds.get(vmDetail.getNodeId())) : null, request.getCreatedAt(), request.getUpdatedAt())); } return details; @@ -103,8 +137,13 @@ private static List idsOfType(List requests, ResourceType type) { .toList(); } + private static Map publicIds(List rows, Function idOf, + Function publicIdOf) { + return rows.stream().collect(Collectors.toMap(idOf, publicIdOf)); + } + private static Set ids(Stream stream) { - return stream.collect(Collectors.toSet()); + return stream.filter(java.util.Objects::nonNull).collect(Collectors.toSet()); } private static Map byId(List entities, Function idOf) { diff --git a/src/main/java/kr/ac/pusan/pickle/request/RequestController.java b/src/main/java/kr/ac/pusan/pickle/request/RequestController.java index 18fb02b6..e83e1470 100644 --- a/src/main/java/kr/ac/pusan/pickle/request/RequestController.java +++ b/src/main/java/kr/ac/pusan/pickle/request/RequestController.java @@ -6,6 +6,7 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; +import java.util.UUID; import kr.ac.pusan.pickle.common.web.PageResponse; import kr.ac.pusan.pickle.access.ResourceType; import kr.ac.pusan.pickle.security.AuthenticatedUser; @@ -47,7 +48,7 @@ public PageResponse listRequests( @AuthenticationPrincipal AuthenticatedUser principal, @RequestParam(required = false) RequestStatus status, @RequestParam(required = false) ResourceType type, - @RequestParam(required = false) Long workspaceId, + @RequestParam(required = false) UUID workspaceId, @RequestParam(defaultValue = "0") @Min(0) int page, @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) { return requestService.list(principal, status, type, workspaceId, page, size); @@ -55,13 +56,13 @@ public PageResponse listRequests( @GetMapping("/{requestId}") public RequestDetailResponse getRequest(@AuthenticationPrincipal AuthenticatedUser principal, - @PathVariable long requestId) { + @PathVariable UUID requestId) { return requestService.get(principal, requestId); } @PostMapping("/{requestId}/cancel") public RequestDetailResponse cancelRequest(@AuthenticationPrincipal AuthenticatedUser principal, - @PathVariable long requestId, + @PathVariable UUID requestId, HttpServletRequest httpRequest) { return requestService.cancel(principal, requestId, clientIp(httpRequest)); } diff --git a/src/main/java/kr/ac/pusan/pickle/request/RequestRepository.java b/src/main/java/kr/ac/pusan/pickle/request/RequestRepository.java index ea9247a3..f08dba8e 100644 --- a/src/main/java/kr/ac/pusan/pickle/request/RequestRepository.java +++ b/src/main/java/kr/ac/pusan/pickle/request/RequestRepository.java @@ -3,6 +3,7 @@ import jakarta.persistence.LockModeType; import java.util.List; import java.util.Optional; +import java.util.UUID; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; @@ -13,13 +14,16 @@ public interface RequestRepository extends JpaRepository, JpaSpecificationExecutor { + /** Resolution of the identifier this row wears outside the API boundary. */ + Optional findByPublicId(UUID publicId); + /** * Locked lookup for decision/cancel mutations: concurrent decisions on the * same request serialize, so exactly one wins and the rest see 409. */ @Lock(LockModeType.PESSIMISTIC_WRITE) - @Query("select r from Request r where r.id = :id") - Optional findWithLockById(@Param("id") Long id); + @Query("select r from Request r where r.publicId = :publicId") + Optional findWithLockByPublicId(@Param("publicId") UUID publicId); /** All requests of a workspace in a given status (workspace-delete cancels its SUBMITTED ones). */ List findByWorkspaceIdAndStatus(Long workspaceId, RequestStatus status); 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 e67dcfe4..07245279 100644 --- a/src/main/java/kr/ac/pusan/pickle/request/RequestService.java +++ b/src/main/java/kr/ac/pusan/pickle/request/RequestService.java @@ -4,6 +4,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.UUID; import java.util.function.Function; import java.util.stream.Collectors; import kr.ac.pusan.pickle.access.ResourceType; @@ -81,7 +82,7 @@ private RequestTypeHandler handlerFor(ResourceType type) { public RequestDetailResponse create(AuthenticatedUser actor, CreateRequestRequest form, String ip) { RequestTypeHandler handler = handlerFor(form.type()); // A soft-deleted workspace cannot receive new requests. - Workspace workspace = workspaceRepository.findByIdAndDeletedAtIsNull(form.workspaceId()) + Workspace workspace = workspaceRepository.findByPublicIdAndDeletedAtIsNull(form.workspaceId()) .orElseThrow(() -> notFound("해당 워크스페이스가 존재하지 않습니다.")); // Any member may ask. The rung that used to gate this was really about // reaching VMs, which is now the access list's business, and asking is @@ -89,7 +90,7 @@ public RequestDetailResponse create(AuthenticatedUser actor, CreateRequestReques workspaceMemberRepository.findByWorkspaceIdAndUserId(workspace.getId(), actor.id()) .orElseThrow(RequestService::notWorkspaceMember); - Org org = orgRepository.findById(form.orgId()) + Org org = orgRepository.findByPublicId(form.orgId()) .orElseThrow(() -> notFound("해당 기관이 존재하지 않습니다.")); if (org.getStatus() != OrgStatus.ACTIVE) { throw ApiException.validationFailed(List.of(new FieldValidationError("orgId", @@ -115,30 +116,35 @@ public RequestDetailResponse create(AuthenticatedUser actor, CreateRequestReques auditArgs.put("orgId", org.getId()); auditArgs.putAll(handler.submitAuditArgs(saved)); auditService.record(actor.id(), actor.role().name(), AuditService.REQUEST_CREATE, - "request", saved.getId(), auditArgs, ip); + "request", saved.getPublicId(), auditArgs, ip); // In-tx inserts: the notices exist iff the request row committed. notificationService.publish(actor.id(), NotificationEvent.REQUEST_SUBMITTED, - Map.of("requestId", saved.getId(), "workspaceName", workspace.getName(), + Map.of("requestId", saved.getPublicId(), "workspaceName", workspace.getName(), "purpose", saved.getPurpose(), "type", form.type().name()), null); notificationService.publish( notificationService.orgAdminIds(org.getId()).stream() .filter(adminId -> !adminId.equals(actor.id())).toList(), NotificationEvent.REQUEST_SUBMITTED, - Map.of("requestId", saved.getId(), "workspaceName", workspace.getName(), + Map.of("requestId", saved.getPublicId(), "workspaceName", workspace.getName(), "purpose", saved.getPurpose(), "type", form.type().name(), "admin", true), null); return assembler.toDetail(saved); } @Transactional(readOnly = true) public PageResponse list(AuthenticatedUser actor, RequestStatus status, - ResourceType type, Long workspaceId, int page, int size) { + ResourceType type, UUID workspaceId, int page, int size) { Specification spec; if (workspaceId != null) { - if (workspaceMemberRepository.findByWorkspaceIdAndUserId(workspaceId, actor.id()).isEmpty()) { + // Unknown id and one I am not a member of answer the same 403, so a + // workspace's existence stays private here as it did before. + Long scopedWorkspaceId = workspaceRepository.findByPublicId(workspaceId) + .map(Workspace::getId).orElse(null); + if (scopedWorkspaceId == null || workspaceMemberRepository + .findByWorkspaceIdAndUserId(scopedWorkspaceId, actor.id()).isEmpty()) { throw new ApiException(HttpStatus.FORBIDDEN, ErrorCodes.ACCESS_DENIED, "접근 권한이 없습니다", "해당 워크스페이스의 신청을 조회할 권한이 없습니다."); } - spec = RequestSpecs.workspace(workspaceId); + spec = RequestSpecs.workspace(scopedWorkspaceId); } else { spec = RequestSpecs.visibleTo(actor.id(), myWorkspaceIds(actor)); } @@ -153,8 +159,8 @@ public PageResponse list(AuthenticatedUser actor, Request } @Transactional(readOnly = true) - public RequestDetailResponse get(AuthenticatedUser actor, long requestId) { - Request request = requestRepository.findById(requestId) + public RequestDetailResponse get(AuthenticatedUser actor, UUID requestId) { + Request request = requestRepository.findByPublicId(requestId) .orElseThrow(() -> notFound("해당 신청이 존재하지 않습니다.")); boolean participant = request.getRequesterId().equals(actor.id()) || workspaceMemberRepository.findByWorkspaceIdAndUserId(request.getWorkspaceId(), actor.id()).isPresent(); @@ -166,8 +172,8 @@ public RequestDetailResponse get(AuthenticatedUser actor, long requestId) { } @Transactional - public RequestDetailResponse cancel(AuthenticatedUser actor, long requestId, String ip) { - Request request = requestRepository.findWithLockById(requestId) + public RequestDetailResponse cancel(AuthenticatedUser actor, UUID requestId, String ip) { + Request request = requestRepository.findWithLockByPublicId(requestId) .orElseThrow(() -> notFound("해당 신청이 존재하지 않습니다.")); boolean requester = request.getRequesterId().equals(actor.id()); boolean workspaceOwner = workspaceMemberRepository @@ -185,7 +191,7 @@ public RequestDetailResponse cancel(AuthenticatedUser actor, long requestId, Str } request.setStatus(RequestStatus.CANCELED); auditService.record(actor.id(), actor.role().name(), AuditService.REQUEST_CANCEL, - "request", request.getId(), Map.of("workspaceId", request.getWorkspaceId()), ip); + "request", request.getPublicId(), Map.of("workspaceId", request.getWorkspaceId()), ip); return assembler.toDetail(request); } diff --git a/src/main/java/kr/ac/pusan/pickle/request/dto/CreateRequestRequest.java b/src/main/java/kr/ac/pusan/pickle/request/dto/CreateRequestRequest.java index f33fe5ce..2ab1faba 100644 --- a/src/main/java/kr/ac/pusan/pickle/request/dto/CreateRequestRequest.java +++ b/src/main/java/kr/ac/pusan/pickle/request/dto/CreateRequestRequest.java @@ -5,6 +5,7 @@ import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import java.time.LocalDate; +import java.util.UUID; import kr.ac.pusan.pickle.access.ResourceType; import kr.ac.pusan.pickle.request.vm.dto.CreateVmRequestSpec; import org.jspecify.annotations.Nullable; @@ -24,10 +25,10 @@ public record CreateRequestRequest( ResourceType type, @NotNull(message = "신청 워크스페이스(workspaceId)를 지정해 주세요.") - Long workspaceId, + UUID workspaceId, @NotNull(message = "기관(orgId)을 지정해 주세요.") - Long orgId, + UUID orgId, @NotBlank(message = "사용 목적을 입력해 주세요.") @Size(max = 2000, message = "사용 목적은 2000자 이하여야 합니다.") diff --git a/src/main/java/kr/ac/pusan/pickle/request/dto/RequestDetailResponse.java b/src/main/java/kr/ac/pusan/pickle/request/dto/RequestDetailResponse.java index 48dd7384..9b0c4beb 100644 --- a/src/main/java/kr/ac/pusan/pickle/request/dto/RequestDetailResponse.java +++ b/src/main/java/kr/ac/pusan/pickle/request/dto/RequestDetailResponse.java @@ -1,7 +1,9 @@ package kr.ac.pusan.pickle.request.dto; +import io.swagger.v3.oas.annotations.media.Schema; import java.time.Instant; import java.time.LocalDate; +import java.util.UUID; import kr.ac.pusan.pickle.access.ResourceType; import kr.ac.pusan.pickle.request.RequestStatus; import kr.ac.pusan.pickle.request.vm.dto.VmRequestSpecResponse; @@ -13,13 +15,16 @@ * populated: the one named by {@code type}. */ public record RequestDetailResponse( - Long id, + UUID id, ResourceType type, - Long workspaceId, + @Schema(description = "신청 대상 워크스페이스. 행이 사라진 경우에만 null입니다.") + @Nullable UUID workspaceId, String workspaceName, - Long orgId, + @Schema(description = "신청 대상 기관. 행이 사라진 경우에만 null입니다.") + @Nullable UUID orgId, String orgName, - Long requesterId, + @Schema(description = "신청자. 행이 사라진 경우에만 null입니다.") + @Nullable UUID requesterId, String requesterName, String purpose, @Nullable String courseOrProject, diff --git a/src/main/java/kr/ac/pusan/pickle/request/dto/RequestReviewResponse.java b/src/main/java/kr/ac/pusan/pickle/request/dto/RequestReviewResponse.java index 81764da8..69ee575c 100644 --- a/src/main/java/kr/ac/pusan/pickle/request/dto/RequestReviewResponse.java +++ b/src/main/java/kr/ac/pusan/pickle/request/dto/RequestReviewResponse.java @@ -2,6 +2,8 @@ import java.time.Instant; import java.time.LocalDate; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.UUID; import kr.ac.pusan.pickle.user.User; import kr.ac.pusan.pickle.request.ReviewDecision; import kr.ac.pusan.pickle.request.RequestReview; @@ -13,7 +15,8 @@ * request's per-type member, since only the period is common to every type. */ public record RequestReviewResponse( - Long reviewerId, + @Schema(description = "결재자. 계정 행이 사라진 경우에만 null입니다.") + @Nullable UUID reviewerId, String reviewerName, ReviewDecision decision, @Nullable String comment, @@ -22,7 +25,7 @@ public record RequestReviewResponse( Instant decidedAt) { public static RequestReviewResponse from(RequestReview review, User reviewer) { - return new RequestReviewResponse(review.getReviewerId(), + return new RequestReviewResponse(reviewer != null ? reviewer.getPublicId() : null, reviewer != null ? reviewer.getName() : "탈퇴 회원", review.getDecision(), review.getComment(), review.getGrantedStartDate(), review.getGrantedEndDate(), review.getCreatedAt()); diff --git a/src/main/java/kr/ac/pusan/pickle/request/vm/VmRequestSupport.java b/src/main/java/kr/ac/pusan/pickle/request/vm/VmRequestSupport.java index 587a3524..88b44cd9 100644 --- a/src/main/java/kr/ac/pusan/pickle/request/vm/VmRequestSupport.java +++ b/src/main/java/kr/ac/pusan/pickle/request/vm/VmRequestSupport.java @@ -81,9 +81,9 @@ public void validateCreate(CreateRequestRequest form, List // A reference to something that does not exist is a 404 here, as it is // for the workspace and the organisation; only a row that exists but may // no longer be chosen is a validation error. - OsImage image = imageRepository.findById(spec.imageId()) + OsImage image = imageRepository.findByPublicId(spec.imageId()) .orElseThrow(() -> notFound("해당 OS 이미지가 존재하지 않습니다.")); - VmFlavor flavor = flavorRepository.findById(spec.flavorId()) + VmFlavor flavor = flavorRepository.findByPublicId(spec.flavorId()) .orElseThrow(() -> notFound("해당 사양 프리셋이 존재하지 않습니다.")); boolean axesActive = true; if (image.getStatus() != CatalogStatus.ACTIVE) { @@ -103,7 +103,10 @@ public void validateCreate(CreateRequestRequest form, List @Override public void saveDetail(Request request, CreateRequestRequest form) { CreateVmRequestSpec spec = form.vm(); - detailRepository.save(new VmRequestDetail(request.getId(), spec.imageId(), spec.flavorId(), + // validateCreate already 404'd on an unknown reference, so both resolve. + long imageId = imageRepository.findByPublicId(spec.imageId()).orElseThrow().getId(); + long flavorId = flavorRepository.findByPublicId(spec.flavorId()).orElseThrow().getId(); + detailRepository.save(new VmRequestDetail(request.getId(), imageId, flavorId, spec.reqVcpu(), spec.reqMemoryMb(), spec.reqDiskGb(), Texts.blankToNull(spec.specReason()), Texts.blankToNull(spec.desiredSlug()))); } @@ -123,7 +126,7 @@ public void validateApprove(Request request, ApproveRequestRequest form, errors.add(new FieldValidationError("vm", "VM 승인 항목(vm)을 입력해 주세요.")); return; } - OsImage image = imageRepository.findById(spec.grantedImageId()).orElse(null); + OsImage image = imageRepository.findByPublicId(spec.grantedImageId()).orElse(null); if (image == null || image.getStatus() != CatalogStatus.ACTIVE) { errors.add(new FieldValidationError("vm.grantedImageId", "사용할 수 없는 OS 이미지입니다.")); } else if (spec.grantedDiskGb() < image.getMinDiskGb()) { @@ -131,10 +134,12 @@ public void validateApprove(Request request, ApproveRequestRequest form, "이 OS 이미지의 최소 디스크 크기는 " + image.getMinDiskGb() + "GiB입니다.")); } if (spec.nodeId() != null) { - if (!nodeRepository.existsById(spec.nodeId())) { + Long nodeId = nodeRepository.findByPublicId(spec.nodeId()) + .map(kr.ac.pusan.pickle.inventory.Node::getId).orElse(null); + if (nodeId == null) { errors.add(new FieldValidationError("vm.nodeId", "존재하지 않는 노드입니다.")); } else if (image != null && !imageRepository.existsByNameAndNodeIdAndStatus( - image.getName(), spec.nodeId(), CatalogStatus.ACTIVE)) { + image.getName(), nodeId, CatalogStatus.ACTIVE)) { // Forced node must host the granted image — the provisioning // pipeline clones the image on the placed node, so a node without it // guarantees a mid-pipeline clone failure. @@ -162,14 +167,17 @@ public void validateApprove(Request request, ApproveRequestRequest form, @Override public Materialized materialize(Request request, ApproveRequestRequest form, AuthenticatedUser actor) { ApproveVmRequestSpec spec = form.vm(); - OsImage image = imageRepository.findById(spec.grantedImageId()).orElseThrow(); + OsImage image = imageRepository.findByPublicId(spec.grantedImageId()).orElseThrow(); VmRequestDetail detail = detail(request); + Long forcedNodeId = spec.nodeId() == null ? null + : nodeRepository.findByPublicId(spec.nodeId()) + .map(kr.ac.pusan.pickle.inventory.Node::getId).orElseThrow(); detail.grant(spec.grantedVcpu(), spec.grantedMemoryMb(), spec.grantedDiskGb(), - image.getId(), spec.nodeId()); + image.getId(), forcedNodeId); // Auto placement: the image's node (single-node cluster; the // scoring placement step arrives with the provisioning pipeline). - Long nodeId = spec.nodeId() != null ? spec.nodeId() : image.getNodeId(); + Long nodeId = forcedNodeId != null ? forcedNodeId : image.getNodeId(); String grantedSlug = Texts.blankToNull(spec.grantedSlug()); String hostname = grantedSlug != null ? grantedSlug : generateHostname(VmSlugPolicy.sanitizeSeed(request.getDisplayName(), diff --git a/src/main/java/kr/ac/pusan/pickle/request/vm/dto/CreateVmRequestSpec.java b/src/main/java/kr/ac/pusan/pickle/request/vm/dto/CreateVmRequestSpec.java index 22632d15..ba233438 100644 --- a/src/main/java/kr/ac/pusan/pickle/request/vm/dto/CreateVmRequestSpec.java +++ b/src/main/java/kr/ac/pusan/pickle/request/vm/dto/CreateVmRequestSpec.java @@ -4,6 +4,7 @@ import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; +import java.util.UUID; import org.jspecify.annotations.Nullable; /** @@ -13,10 +14,10 @@ */ public record CreateVmRequestSpec( @NotNull(message = "OS 이미지(imageId)를 지정해 주세요.") - Long imageId, + UUID imageId, @NotNull(message = "사양 프리셋(flavorId)을 지정해 주세요.") - Long flavorId, + UUID flavorId, @NotNull(message = "요청 vCPU 수를 입력해 주세요.") @Min(value = 1, message = "vCPU는 1 이상이어야 합니다.") diff --git a/src/main/java/kr/ac/pusan/pickle/request/vm/dto/VmGrantedSpecResponse.java b/src/main/java/kr/ac/pusan/pickle/request/vm/dto/VmGrantedSpecResponse.java index 3acf0402..34410dce 100644 --- a/src/main/java/kr/ac/pusan/pickle/request/vm/dto/VmGrantedSpecResponse.java +++ b/src/main/java/kr/ac/pusan/pickle/request/vm/dto/VmGrantedSpecResponse.java @@ -1,12 +1,13 @@ package kr.ac.pusan.pickle.request.vm.dto; import org.jspecify.annotations.Nullable; +import java.util.UUID; /** Contract schema {@code VmGrantedSpec}: what the reviewer granted for a VM. */ public record VmGrantedSpecResponse( Integer grantedVcpu, Integer grantedMemoryMb, Integer grantedDiskGb, - Long grantedImageId, - @Nullable Long nodeId) { + UUID grantedImageId, + @Nullable UUID nodeId) { } diff --git a/src/main/java/kr/ac/pusan/pickle/request/vm/dto/VmRequestSpecResponse.java b/src/main/java/kr/ac/pusan/pickle/request/vm/dto/VmRequestSpecResponse.java index 79bbf700..22683ac8 100644 --- a/src/main/java/kr/ac/pusan/pickle/request/vm/dto/VmRequestSpecResponse.java +++ b/src/main/java/kr/ac/pusan/pickle/request/vm/dto/VmRequestSpecResponse.java @@ -1,6 +1,7 @@ package kr.ac.pusan.pickle.request.vm.dto; import kr.ac.pusan.pickle.request.vm.VmRequestDetail; +import java.util.UUID; import org.jspecify.annotations.Nullable; /** @@ -9,8 +10,8 @@ * request is approved. */ public record VmRequestSpecResponse( - Long imageId, - @Nullable Long flavorId, + UUID imageId, + @Nullable UUID flavorId, int reqVcpu, int reqMemoryMb, int reqDiskGb, @@ -20,12 +21,13 @@ public record VmRequestSpecResponse( @Nullable String rootDomain, @Nullable VmGrantedSpecResponse granted) { - public static VmRequestSpecResponse from(VmRequestDetail detail) { + public static VmRequestSpecResponse from(VmRequestDetail detail, UUID imageId, UUID flavorId, + UUID grantedImageId, UUID nodeId) { VmGrantedSpecResponse granted = detail.getGrantedVcpu() != null ? new VmGrantedSpecResponse(detail.getGrantedVcpu(), detail.getGrantedMemoryMb(), - detail.getGrantedDiskGb(), detail.getGrantedImageId(), detail.getNodeId()) + detail.getGrantedDiskGb(), grantedImageId, nodeId) : null; - return new VmRequestSpecResponse(detail.getImageId(), detail.getFlavorId(), + return new VmRequestSpecResponse(imageId, flavorId, detail.getReqVcpu(), detail.getReqMemoryMb(), detail.getReqDiskGb(), detail.getSpecReason(), detail.getDesiredSlug(), detail.getDesiredSubdomain(), detail.getRootDomain(), granted); diff --git a/src/main/java/kr/ac/pusan/pickle/resource/ResourceIdentity.java b/src/main/java/kr/ac/pusan/pickle/resource/ResourceIdentity.java index 2603bd17..0f46e1c4 100644 --- a/src/main/java/kr/ac/pusan/pickle/resource/ResourceIdentity.java +++ b/src/main/java/kr/ac/pusan/pickle/resource/ResourceIdentity.java @@ -1,5 +1,6 @@ package kr.ac.pusan.pickle.resource; +import java.util.UUID; import org.jspecify.annotations.Nullable; /** @@ -13,12 +14,13 @@ * cannot leak past the limited view even by accident. * * @param id the resource's id within its type + * @param publicId the identifier it wears outside the API boundary * @param workspaceId the workspace that owns it, and whose owners hold standing * rights over it * @param name the name it is listed under * @param displayName the name its owners gave it, or null if they gave none * @param status this type's own state vocabulary, as a plain string */ -public record ResourceIdentity(long id, long workspaceId, String name, +public record ResourceIdentity(long id, UUID publicId, long workspaceId, String name, @Nullable String displayName, String status) { } diff --git a/src/main/java/kr/ac/pusan/pickle/resource/ResourceIndexController.java b/src/main/java/kr/ac/pusan/pickle/resource/ResourceIndexController.java index 5710ca39..c199aab3 100644 --- a/src/main/java/kr/ac/pusan/pickle/resource/ResourceIndexController.java +++ b/src/main/java/kr/ac/pusan/pickle/resource/ResourceIndexController.java @@ -2,6 +2,7 @@ import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; +import java.util.UUID; import kr.ac.pusan.pickle.access.ResourceType; import kr.ac.pusan.pickle.common.web.PageResponse; import kr.ac.pusan.pickle.resource.dto.ResourceSummaryResponse; @@ -27,7 +28,7 @@ public ResourceIndexController(ResourceIndexService resourceIndexService) { public PageResponse listResources( @AuthenticationPrincipal AuthenticatedUser principal, @RequestParam(required = false) ResourceType type, - @RequestParam(required = false) Long workspaceId, + @RequestParam(required = false) UUID workspaceId, @RequestParam(defaultValue = "0") @Min(0) int page, @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) { return resourceIndexService.list(principal, type, workspaceId, page, size); diff --git a/src/main/java/kr/ac/pusan/pickle/resource/ResourceIndexService.java b/src/main/java/kr/ac/pusan/pickle/resource/ResourceIndexService.java index 7a310e7b..264d71e6 100644 --- a/src/main/java/kr/ac/pusan/pickle/resource/ResourceIndexService.java +++ b/src/main/java/kr/ac/pusan/pickle/resource/ResourceIndexService.java @@ -2,6 +2,7 @@ import java.util.List; import java.util.Map; +import java.util.UUID; import java.util.function.Function; import java.util.stream.Collectors; import kr.ac.pusan.pickle.access.ResourceType; @@ -38,7 +39,7 @@ public ResourceIndexService(List adapters) { @Transactional(readOnly = true) public PageResponse list(AuthenticatedUser actor, ResourceType type, - Long workspaceId, int page, int size) { + UUID workspaceId, int page, int size) { // Newest first by creation time rather than by id: an id is an opaque // handle, and ordering by it would break the day one stops being a // number. 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 22fc50f8..993d61ee 100644 --- a/src/main/java/kr/ac/pusan/pickle/resource/ResourceTypeAdapter.java +++ b/src/main/java/kr/ac/pusan/pickle/resource/ResourceTypeAdapter.java @@ -2,6 +2,7 @@ import java.util.List; import java.util.Optional; +import java.util.UUID; import kr.ac.pusan.pickle.access.ResourceAccessAudit; import kr.ac.pusan.pickle.access.ResourceAccessMessages; import kr.ac.pusan.pickle.access.ResourceType; @@ -39,6 +40,14 @@ public interface ResourceTypeAdapter { */ Optional identify(long resourceId); + /** + * The same, reached by the identifier the resource wears outside the API + * boundary. This is the form every request-scoped lookup takes: an id that + * arrived over HTTP is resolved here once, and everything below works on + * the internal id the returned identity carries. + */ + Optional identifyByPublicId(UUID publicId); + /** * What this type says when it refuses — the only part of the access rules * that is allowed to differ between types. @@ -74,5 +83,5 @@ public interface ResourceTypeAdapter { * requester holds no grant on comes back limited rather than omitted, so * they can see it exists and ask. */ - Page page(AuthenticatedUser actor, Long workspaceId, Pageable pageable); + Page page(AuthenticatedUser actor, UUID workspaceId, Pageable pageable); } 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 18f91777..eefc34a7 100644 --- a/src/main/java/kr/ac/pusan/pickle/resource/VmResourceAdapter.java +++ b/src/main/java/kr/ac/pusan/pickle/resource/VmResourceAdapter.java @@ -2,6 +2,8 @@ import java.util.List; import java.util.Optional; +import kr.ac.pusan.pickle.vm.Vm; +import java.util.UUID; import kr.ac.pusan.pickle.access.ResourceAccessAudit; import kr.ac.pusan.pickle.access.ResourceAccessMessages; import kr.ac.pusan.pickle.access.ResourceType; @@ -64,10 +66,18 @@ public Optional identify(long resourceId) { // No filter on status: a destroyed VM keeps its row and its access // list, which is what lets the people who used it still read its // history. - return vmRepository.findById(resourceId) - .map(vm -> new ResourceIdentity(vm.getId(), vm.getWorkspaceId(), vm.getName(), - vmSettingsService.string(vm.getId(), VmSettingsService.DISPLAY_NAME), - vm.getStatus().name())); + return vmRepository.findById(resourceId).map(this::identityOf); + } + + @Override + public Optional identifyByPublicId(UUID publicId) { + return vmRepository.findByPublicId(publicId).map(this::identityOf); + } + + private ResourceIdentity identityOf(Vm vm) { + return new ResourceIdentity(vm.getId(), vm.getPublicId(), vm.getWorkspaceId(), vm.getName(), + vmSettingsService.string(vm.getId(), VmSettingsService.DISPLAY_NAME), + vm.getStatus().name()); } @Override @@ -92,7 +102,7 @@ public long countLiveInWorkspace(long workspaceId) { } @Override - public Page page(AuthenticatedUser actor, Long workspaceId, + public Page page(AuthenticatedUser actor, UUID workspaceId, Pageable pageable) { // Reuses the VM list rather than re-deriving visibility: the masking // rules live in one place, so the inventory cannot drift into showing diff --git a/src/main/java/kr/ac/pusan/pickle/resource/dto/ResourceSummaryResponse.java b/src/main/java/kr/ac/pusan/pickle/resource/dto/ResourceSummaryResponse.java index da3060db..95c0962b 100644 --- a/src/main/java/kr/ac/pusan/pickle/resource/dto/ResourceSummaryResponse.java +++ b/src/main/java/kr/ac/pusan/pickle/resource/dto/ResourceSummaryResponse.java @@ -2,6 +2,7 @@ import java.time.Instant; import java.util.List; +import java.util.UUID; import kr.ac.pusan.pickle.access.ResourceType; import org.jspecify.annotations.Nullable; @@ -20,12 +21,12 @@ * meaning neither side has. */ public record ResourceSummaryResponse( - Long id, + UUID id, ResourceType type, String name, @Nullable String displayName, String status, - Long workspaceId, + UUID workspaceId, String workspaceName, /** True when the caller may see that it exists but not what it is. */ boolean accessLimited, diff --git a/src/main/java/kr/ac/pusan/pickle/security/AuthenticatedUser.java b/src/main/java/kr/ac/pusan/pickle/security/AuthenticatedUser.java index 62544db9..870f4f7e 100644 --- a/src/main/java/kr/ac/pusan/pickle/security/AuthenticatedUser.java +++ b/src/main/java/kr/ac/pusan/pickle/security/AuthenticatedUser.java @@ -1,7 +1,15 @@ package kr.ac.pusan.pickle.security; +import java.util.UUID; import kr.ac.pusan.pickle.user.UserRole; -/** Authenticated principal placed in the SecurityContext by the JWT filter. */ -public record AuthenticatedUser(Long id, String email, UserRole role, Long orgId) { +/** + * Authenticated principal placed in the SecurityContext by the JWT filter. + * + *

{@code id} is the internal key every query joins on; {@code publicId} is + * the same account as the API names it, carried here so a response that has to + * report who acted does not need a second read of the row the filter already + * loaded. + */ +public record AuthenticatedUser(Long id, UUID publicId, String email, UserRole role, Long orgId) { } diff --git a/src/main/java/kr/ac/pusan/pickle/security/JwtAuthenticationFilter.java b/src/main/java/kr/ac/pusan/pickle/security/JwtAuthenticationFilter.java index 8aa2790c..bbc1e015 100644 --- a/src/main/java/kr/ac/pusan/pickle/security/JwtAuthenticationFilter.java +++ b/src/main/java/kr/ac/pusan/pickle/security/JwtAuthenticationFilter.java @@ -8,6 +8,7 @@ import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.List; +import java.util.UUID; import kr.ac.pusan.pickle.user.User; import kr.ac.pusan.pickle.user.UserRepository; import kr.ac.pusan.pickle.user.UserStatus; @@ -50,12 +51,14 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse private void authenticate(HttpServletRequest request, String token) { try { Claims claims = jwtService.parse(token); - long userId = Long.parseLong(claims.getSubject()); + // A subject that is not a UUID throws IllegalArgumentException, which + // the catch below already treats as an invalid token. + UUID userId = UUID.fromString(claims.getSubject()); Integer tokenVersion = claims.get(JwtService.CLAIM_TOKEN_VERSION, Integer.class); if (tokenVersion == null) { return; } - userRepository.findById(userId) + userRepository.findByPublicId(userId) .filter(user -> user.getStatus() == UserStatus.ACTIVE) .filter(user -> user.getTokenVersion() == tokenVersion) .ifPresent(user -> setAuthentication(request, user)); @@ -67,7 +70,8 @@ private void authenticate(HttpServletRequest request, String token) { private void setAuthentication(HttpServletRequest request, User user) { AuthenticatedUser principal = - new AuthenticatedUser(user.getId(), user.getEmail(), user.getRole(), user.getOrgId()); + new AuthenticatedUser(user.getId(), user.getPublicId(), user.getEmail(), + user.getRole(), user.getOrgId()); var authentication = new UsernamePasswordAuthenticationToken(principal, null, List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole().name()))); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); diff --git a/src/main/java/kr/ac/pusan/pickle/security/JwtService.java b/src/main/java/kr/ac/pusan/pickle/security/JwtService.java index c87a37d9..012c98d6 100644 --- a/src/main/java/kr/ac/pusan/pickle/security/JwtService.java +++ b/src/main/java/kr/ac/pusan/pickle/security/JwtService.java @@ -14,13 +14,18 @@ /** * Access token (JWT HS256, 15 min). Claims per contract securityScheme: - * {@code sub}, {@code role}, {@code org_id}, {@code token_version}. + * {@code sub}, {@code role}, {@code token_version}. + * + *

{@code sub} is the account's public identifier. The token is readable by + * anyone holding it, so the internal key has no business in it — and neither + * did the organisation's, which is why {@code org_id} was dropped: it was + * written and never read, and it disclosed the org's sequential id to every + * client that decoded a token. */ @Component public class JwtService { public static final String CLAIM_ROLE = "role"; - public static final String CLAIM_ORG_ID = "org_id"; public static final String CLAIM_TOKEN_VERSION = "token_version"; private final SecretKey key; @@ -43,16 +48,13 @@ public JwtService(JwtProperties properties) { public String createAccessToken(User user) { Instant now = Instant.now(); - var builder = Jwts.builder() - .subject(String.valueOf(user.getId())) + return Jwts.builder() + .subject(user.getPublicId().toString()) .claim(CLAIM_ROLE, user.getRole().name()) .claim(CLAIM_TOKEN_VERSION, user.getTokenVersion()) .issuedAt(Date.from(now)) - .expiration(Date.from(now.plus(properties.accessTokenTtl()))); - if (user.getOrgId() != null) { - builder.claim(CLAIM_ORG_ID, user.getOrgId()); - } - return builder.signWith(key, Jwts.SIG.HS256).compact(); + .expiration(Date.from(now.plus(properties.accessTokenTtl()))) + .signWith(key, Jwts.SIG.HS256).compact(); } /** @throws io.jsonwebtoken.JwtException when invalid/expired */ diff --git a/src/main/java/kr/ac/pusan/pickle/seed/DevDataSeeder.java b/src/main/java/kr/ac/pusan/pickle/seed/DevDataSeeder.java index 7ef0976c..b4fecdc7 100644 --- a/src/main/java/kr/ac/pusan/pickle/seed/DevDataSeeder.java +++ b/src/main/java/kr/ac/pusan/pickle/seed/DevDataSeeder.java @@ -44,7 +44,6 @@ public class DevDataSeeder implements ApplicationRunner { public static final int SEED_TEMPLATE_VMID = 1001; public static final String ORG_NAME = "테스트 기관"; - public static final String ORG_SLUG = "test-org"; private final UserRepository userRepository; private final OrgRepository orgRepository; @@ -79,10 +78,10 @@ public void run(ApplicationArguments args) { seedUser(properties.sysadminEmail(), properties.sysadminPassword(), "시스템 관리자", UserRole.SYS_ADMIN, null); - Org org = orgRepository.findBySlug(ORG_SLUG) + Org org = orgRepository.findFirstByNameOrderByIdAsc(ORG_NAME) .orElseGet(() -> { - log.info("Seeding org '{}' ({})", ORG_NAME, ORG_SLUG); - Org seedOrg = new Org(ORG_NAME, ORG_SLUG, ORG_NAME + " (개발용 시드 기관)"); + log.info("Seeding org '{}'", ORG_NAME); + Org seedOrg = new Org(ORG_NAME, ORG_NAME + " (개발용 시드 기관)"); seedOrg.setHidden(true); return orgRepository.save(seedOrg); }); diff --git a/src/main/java/kr/ac/pusan/pickle/sshgw/SshGatewayRouteService.java b/src/main/java/kr/ac/pusan/pickle/sshgw/SshGatewayRouteService.java index a7628177..6509a722 100644 --- a/src/main/java/kr/ac/pusan/pickle/sshgw/SshGatewayRouteService.java +++ b/src/main/java/kr/ac/pusan/pickle/sshgw/SshGatewayRouteService.java @@ -4,6 +4,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.UUID; import kr.ac.pusan.pickle.access.ResourceRole; import kr.ac.pusan.pickle.access.VmAccessService; import kr.ac.pusan.pickle.audit.AuditService; @@ -211,9 +212,20 @@ private RouteOutcome deny(Context ctx, Long vmId, RouteOutcome outcome) { /** Denials carry a null actor even after identification (see class javadoc). */ private void auditDenied(Context ctx, Long vmId, String reason) { auditService.record(null, AuditService.ACTOR_ROLE_SSHGW, AuditService.SSHGW_ROUTE_DENIED, - "vm", vmId, ctx.detail(reason), ctx.sourceIp()); + "vm", vmPublicId(vmId), ctx.detail(reason), ctx.sourceIp()); } + /** + * The VM's public identifier for the audit trail. The gateway contract + * itself stays on the internal id (a Go client decodes it as int64), so + * the translation happens here, at the audit boundary. + */ + private UUID vmPublicId(Long vmId) { + return vmId == null ? null + : vmRepository.findById(vmId).map(Vm::getPublicId).orElse(null); + } + + /** * Splits the newline-joined stored host keys into one entry per type. Shared * with the web-terminal redeem step (the internal web-terminal contract) so both hops diff --git a/src/main/java/kr/ac/pusan/pickle/sshgw/SshGatewaySessionService.java b/src/main/java/kr/ac/pusan/pickle/sshgw/SshGatewaySessionService.java index 444c9934..2ac291cb 100644 --- a/src/main/java/kr/ac/pusan/pickle/sshgw/SshGatewaySessionService.java +++ b/src/main/java/kr/ac/pusan/pickle/sshgw/SshGatewaySessionService.java @@ -7,6 +7,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.UUID; import kr.ac.pusan.pickle.audit.AuditService; import kr.ac.pusan.pickle.sshgw.dto.SessionRequest; import kr.ac.pusan.pickle.sshkey.UserSshKey; @@ -112,7 +113,7 @@ public void recordSession(SessionRequest request, String gatewayPeer) { } auditService.record(actorId, AuditService.ACTOR_ROLE_SSHGW, AuditService.SSHGW_SESSION, - "vm", vmId, detail, request.sourceIp()); + "vm", vmPublicId(vmId), detail, request.sourceIp()); } catch (RuntimeException e) { // Fire-and-forget: never let an audit failure affect the live session. log.warn("sshgw session audit failed (best-effort) for slug {}: {}", @@ -185,4 +186,14 @@ private static List nonBlank(List values) { } return out; } + + /** + * The VM's public identifier for the audit trail. The gateway contract + * itself stays on the internal id (a Go client decodes it as int64), so + * the translation happens here, at the audit boundary. + */ + private UUID vmPublicId(Long vmId) { + return vmId == null ? null + : vmRepository.findById(vmId).map(Vm::getPublicId).orElse(null); + } } diff --git a/src/main/java/kr/ac/pusan/pickle/sshkey/MeSshKeyController.java b/src/main/java/kr/ac/pusan/pickle/sshkey/MeSshKeyController.java index 5bb662dc..8dc2cdb2 100644 --- a/src/main/java/kr/ac/pusan/pickle/sshkey/MeSshKeyController.java +++ b/src/main/java/kr/ac/pusan/pickle/sshkey/MeSshKeyController.java @@ -5,6 +5,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import java.util.List; +import java.util.UUID; import kr.ac.pusan.pickle.security.AuthenticatedUser; import kr.ac.pusan.pickle.security.RequireReauth; import kr.ac.pusan.pickle.sshkey.dto.SshKeyCreateRequest; @@ -61,7 +62,7 @@ public SshKeyView generateKey(@AuthenticationPrincipal AuthenticatedUser princip @ResponseStatus(HttpStatus.NO_CONTENT) @RequireReauth public void deleteKey(@AuthenticationPrincipal AuthenticatedUser principal, - @PathVariable long keyId, HttpServletRequest httpRequest) { + @PathVariable UUID keyId, HttpServletRequest httpRequest) { service.delete(principal, keyId, clientIp(httpRequest)); } @@ -69,7 +70,7 @@ public void deleteKey(@AuthenticationPrincipal AuthenticatedUser principal, @GetMapping("/{keyId}/private-key") @RequireReauth public ResponseEntity downloadPrivateKey( - @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable long keyId, + @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable UUID keyId, HttpServletRequest httpRequest) { SshKeyPrivateKeyResponse response = service.downloadPrivateKey(principal, keyId, clientIp(httpRequest)); diff --git a/src/main/java/kr/ac/pusan/pickle/sshkey/UserSshKey.java b/src/main/java/kr/ac/pusan/pickle/sshkey/UserSshKey.java index 92f6dd4c..c45548da 100644 --- a/src/main/java/kr/ac/pusan/pickle/sshkey/UserSshKey.java +++ b/src/main/java/kr/ac/pusan/pickle/sshkey/UserSshKey.java @@ -7,8 +7,11 @@ import jakarta.persistence.Id; import jakarta.persistence.Table; import java.time.Instant; +import java.util.UUID; import kr.ac.pusan.pickle.common.crypto.SshKeyAlgorithm; import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; /** * A user's registered SSH public key (V28). Either pasted (server holds only the @@ -24,6 +27,14 @@ public class UserSshKey { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + /** + * The identifier this row wears outside the API boundary. Internal joins, + * sorts and foreign keys keep using {@link #id}. + */ + @JdbcTypeCode(SqlTypes.UUID) + @Column(name = "public_id", nullable = false, updatable = false, unique = true) + private UUID publicId = UUID.randomUUID(); + @Column(name = "user_id", nullable = false) private Long userId; @@ -68,6 +79,10 @@ public Long getId() { return id; } + public UUID getPublicId() { + return publicId; + } + public Long getUserId() { return userId; } diff --git a/src/main/java/kr/ac/pusan/pickle/sshkey/UserSshKeyRepository.java b/src/main/java/kr/ac/pusan/pickle/sshkey/UserSshKeyRepository.java index 8ff2a624..8f2278db 100644 --- a/src/main/java/kr/ac/pusan/pickle/sshkey/UserSshKeyRepository.java +++ b/src/main/java/kr/ac/pusan/pickle/sshkey/UserSshKeyRepository.java @@ -3,6 +3,7 @@ import java.time.Instant; import java.util.List; import java.util.Optional; +import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -12,6 +13,9 @@ public interface UserSshKeyRepository extends JpaRepository { + /** Resolution of the identifier this row wears outside the API boundary. */ + Optional findByPublicId(UUID publicId); + List findByUserIdOrderByCreatedAtAscIdAsc(Long userId); Optional findByIdAndUserId(Long id, Long userId); diff --git a/src/main/java/kr/ac/pusan/pickle/sshkey/UserSshKeyService.java b/src/main/java/kr/ac/pusan/pickle/sshkey/UserSshKeyService.java index 20c7efd4..7e7f1835 100644 --- a/src/main/java/kr/ac/pusan/pickle/sshkey/UserSshKeyService.java +++ b/src/main/java/kr/ac/pusan/pickle/sshkey/UserSshKeyService.java @@ -2,6 +2,7 @@ import java.util.List; import java.util.Map; +import java.util.UUID; import kr.ac.pusan.pickle.audit.AuditService; import kr.ac.pusan.pickle.common.crypto.CredentialCipher; import kr.ac.pusan.pickle.common.crypto.GeneratedSshKeyPair; @@ -85,9 +86,10 @@ public SshKeyView generate(AuthenticatedUser actor, String name, String ip) { * answer 404 (existence masking). */ @Transactional - public SshKeyPrivateKeyResponse downloadPrivateKey(AuthenticatedUser actor, long keyId, + public SshKeyPrivateKeyResponse downloadPrivateKey(AuthenticatedUser actor, UUID keyId, String ip) { - UserSshKey key = repository.findByIdAndUserId(keyId, actor.id()) + UserSshKey key = repository.findByPublicId(keyId) + .filter(row -> actor.id().equals(row.getUserId())) .orElseThrow(UserSshKeyService::keyNotFound); if (key.getPrivateKeyEnc() == null) { throw new ApiException(HttpStatus.NOT_FOUND, ErrorCodes.RESOURCE_NOT_FOUND, @@ -96,18 +98,19 @@ public SshKeyPrivateKeyResponse downloadPrivateKey(AuthenticatedUser actor, long } String privateKey = credentialCipher.decrypt(key.getPrivateKeyEnc()); auditService.recordAfterCommit(actor.id(), actor.role().name(), - AuditService.USER_SSH_KEY_DOWNLOAD, "ssh_key", key.getId(), + AuditService.USER_SSH_KEY_DOWNLOAD, "ssh_key", key.getPublicId(), Map.of("fingerprint", key.getFingerprintSha256()), ip); return new SshKeyPrivateKeyResponse(privateKey, PRIVATE_KEY_FILENAME); } @Transactional - public void delete(AuthenticatedUser actor, long keyId, String ip) { - UserSshKey key = repository.findByIdAndUserId(keyId, actor.id()) + public void delete(AuthenticatedUser actor, UUID keyId, String ip) { + UserSshKey key = repository.findByPublicId(keyId) + .filter(row -> actor.id().equals(row.getUserId())) .orElseThrow(UserSshKeyService::keyNotFound); repository.delete(key); auditService.recordAfterCommit(actor.id(), actor.role().name(), - AuditService.USER_SSH_KEY_DELETE, "ssh_key", key.getId(), + AuditService.USER_SSH_KEY_DELETE, "ssh_key", key.getPublicId(), Map.of("fingerprint", key.getFingerprintSha256()), ip); } @@ -134,7 +137,7 @@ private UserSshKey persist(AuthenticatedUser actor, String name, ParsedSshKey pa parsed.algorithm().wireType(), parsed.normalizedLine(), parsed.fingerprint(), privateKeyEnc)); auditService.recordAfterCommit(actor.id(), actor.role().name(), auditAction, - "ssh_key", saved.getId(), + "ssh_key", saved.getPublicId(), Map.of("fingerprint", saved.getFingerprintSha256(), "algorithm", saved.getAlgorithm()), ip); return saved; diff --git a/src/main/java/kr/ac/pusan/pickle/sshkey/dto/SshKeyView.java b/src/main/java/kr/ac/pusan/pickle/sshkey/dto/SshKeyView.java index 5ba1aa2b..39c67aa2 100644 --- a/src/main/java/kr/ac/pusan/pickle/sshkey/dto/SshKeyView.java +++ b/src/main/java/kr/ac/pusan/pickle/sshkey/dto/SshKeyView.java @@ -1,13 +1,14 @@ package kr.ac.pusan.pickle.sshkey.dto; import java.time.Instant; +import java.util.UUID; import kr.ac.pusan.pickle.common.crypto.SshKeyAlgorithm; import kr.ac.pusan.pickle.sshkey.UserSshKey; import org.jspecify.annotations.Nullable; /** Contract schema {@code SshKeyView} — a registered SSH public key. */ public record SshKeyView( - Long id, + UUID id, String name, SshKeyAlgorithm algorithm, String publicKey, @@ -17,7 +18,7 @@ public record SshKeyView( @Nullable Instant lastUsedAt) { public static SshKeyView from(UserSshKey key) { - return new SshKeyView(key.getId(), key.getName(), key.algorithmEnum(), key.getPublicKey(), + return new SshKeyView(key.getPublicId(), key.getName(), key.algorithmEnum(), key.getPublicKey(), key.getFingerprintSha256(), key.isPrivateKeyStored(), key.getCreatedAt(), key.getLastUsedAt()); } diff --git a/src/main/java/kr/ac/pusan/pickle/terminal/TerminalController.java b/src/main/java/kr/ac/pusan/pickle/terminal/TerminalController.java index 5ef1a0b3..988f2563 100644 --- a/src/main/java/kr/ac/pusan/pickle/terminal/TerminalController.java +++ b/src/main/java/kr/ac/pusan/pickle/terminal/TerminalController.java @@ -3,6 +3,7 @@ import static kr.ac.pusan.pickle.common.web.ClientIps.clientIp; import jakarta.servlet.http.HttpServletRequest; +import java.util.UUID; import kr.ac.pusan.pickle.security.AuthenticatedUser; import kr.ac.pusan.pickle.terminal.dto.TerminalTicketResponse; import org.springframework.http.CacheControl; @@ -37,7 +38,7 @@ public TerminalController(TerminalService terminalService) { @ResponseStatus(HttpStatus.CREATED) public ResponseEntity createTerminalSession( @AuthenticationPrincipal AuthenticatedUser principal, - @PathVariable long vmId, + @PathVariable UUID vmId, HttpServletRequest httpRequest) { TerminalTicketResponse ticket = terminalService.mint(principal, vmId, clientIp(httpRequest)); return ResponseEntity.status(HttpStatus.CREATED) diff --git a/src/main/java/kr/ac/pusan/pickle/terminal/TerminalService.java b/src/main/java/kr/ac/pusan/pickle/terminal/TerminalService.java index 68b8d64c..68cce0db 100644 --- a/src/main/java/kr/ac/pusan/pickle/terminal/TerminalService.java +++ b/src/main/java/kr/ac/pusan/pickle/terminal/TerminalService.java @@ -104,7 +104,7 @@ public TerminalService(SettingsService settingsService, VmRepository vmRepositor * service-layer here (existence-masking 404 for non-members). */ @Transactional(readOnly = true) - public TerminalTicketResponse mint(AuthenticatedUser actor, long vmId, String clientIp) { + public TerminalTicketResponse mint(AuthenticatedUser actor, UUID publicVmId, String clientIp) { // 1) global kill switch first (a disabled feature reveals nothing). if (!settingsService.bool(SettingsService.WEB_TERMINAL_ENABLED, false)) { throw new ApiException(HttpStatus.SERVICE_UNAVAILABLE, ErrorCodes.TERMINAL_DISABLED, @@ -114,7 +114,7 @@ public TerminalTicketResponse mint(AuthenticatedUser actor, long vmId, String cl // 404 (existence stays private); a VIEWER already sees the VM via getVm, // so it gets an honest 403 (same as the power-control paths) rather than // a misleading 404. - Vm vm = vmRepository.findById(vmId).orElseThrow(VmAccessService::vmNotFound); + Vm vm = vmRepository.findByPublicId(publicVmId).orElseThrow(VmAccessService::vmNotFound); vmAccessService.of(vm, actor.id()).requireAtLeast(ResourceRole.MEMBER, "웹 터미널을 열 권한이 없습니다", "이 VM의 참여자 이상만 웹 터미널을 사용할 수 있습니다."); // 3) RUNNING. @@ -232,7 +232,7 @@ public void sessionStart(String sessionId, String clientIp) { detail.put("vmId", s.vmId()); detail.put("clientIp", clientIp); auditService.record(s.userId(), roleName(s.userRole()), AuditService.TERMINAL_SESSION_START, - "vm", s.vmId(), detail, clientIp); + "vm", vmPublicId(s.vmId()), detail, clientIp); } /** @@ -256,7 +256,7 @@ public void sessionEnd(TerminalSessionEndRequest request) { detail.put("bytesIn", request.bytesIn()); detail.put("bytesOut", request.bytesOut()); auditService.record(s.userId(), roleName(s.userRole()), AuditService.TERMINAL_SESSION_END, - "vm", s.vmId(), detail, s.clientIp()); + "vm", vmPublicId(s.vmId()), detail, s.clientIp()); } // ── internal: revalidate ────────────────────────────────────────────────── @@ -306,18 +306,23 @@ public List list(AuthenticatedUser actor) { Map workspaceNames = workspaceRepository.findAllById( vms.values().stream().map(Vm::getWorkspaceId).distinct().toList()) .stream().collect(Collectors.toMap(Workspace::getId, Workspace::getName)); - Map orgNames = orgRepository.findAllById( + Map orgs = orgRepository.findAllById( sessions.stream().map(MirrorSession::orgId).distinct().toList()) - .stream().collect(Collectors.toMap(Org::getId, Org::getName)); + .stream().collect(Collectors.toMap(Org::getId, o -> o)); List views = new ArrayList<>(sessions.size()); for (MirrorSession s : sessions) { + // The mirror holds the internal ids the gateway contract speaks; the + // admin view reports the same rows by their public identifiers. Vm vm = vms.get(s.vmId()); User user = users.get(s.userId()); + Org org = orgs.get(s.orgId()); views.add(new TerminalSessionView( - s.sessionId(), s.vmId(), vm != null ? vm.getName() : "", s.orgId(), - orgNames.getOrDefault(s.orgId(), ""), + s.sessionId(), vm != null ? vm.getPublicId() : null, + vm != null ? vm.getName() : "", org != null ? org.getPublicId() : null, + org != null ? org.getName() : "", vm != null ? workspaceNames.getOrDefault(vm.getWorkspaceId(), "") : "", - s.userId(), user != null ? user.getEmail() : "", + user != null ? user.getPublicId() : null, + user != null ? user.getEmail() : "", user != null ? user.getName() : "", s.clientIp(), s.startedAt())); } return views; @@ -338,7 +343,8 @@ public void terminate(AuthenticatedUser actor, String sessionId, String ip) { detail.put("sessionId", sessionId); detail.put("vmId", known.get().vmId()); auditService.record(actor.id(), actor.role().name(), - AuditService.TERMINAL_FORCE_TERMINATE, "vm", known.get().vmId(), detail, ip); + AuditService.TERMINAL_FORCE_TERMINATE, "vm", vmPublicId(known.get().vmId()), + detail, ip); } } @@ -398,4 +404,14 @@ private static ApiException sessionUserGone() { return new ApiException(HttpStatus.UNAUTHORIZED, ErrorCodes.AUTH_TOKEN_INVALID, "인증이 필요합니다", "액세스 토큰이 없거나 만료되었습니다. 토큰을 갱신한 뒤 다시 시도해 주세요."); } + + /** + * The VM's public identifier for the audit trail. The gateway contract + * itself stays on the internal id (a Go client decodes it as int64), so + * the translation happens here, at the audit boundary. + */ + private UUID vmPublicId(Long vmId) { + return vmId == null ? null + : vmRepository.findById(vmId).map(Vm::getPublicId).orElse(null); + } } diff --git a/src/main/java/kr/ac/pusan/pickle/terminal/dto/TerminalSessionView.java b/src/main/java/kr/ac/pusan/pickle/terminal/dto/TerminalSessionView.java index 266fb177..38732197 100644 --- a/src/main/java/kr/ac/pusan/pickle/terminal/dto/TerminalSessionView.java +++ b/src/main/java/kr/ac/pusan/pickle/terminal/dto/TerminalSessionView.java @@ -1,6 +1,7 @@ package kr.ac.pusan.pickle.terminal.dto; import java.time.Instant; +import java.util.UUID; /** * Live web-terminal session, admin view (contract {@code TerminalSessionView}, @@ -8,7 +9,7 @@ * carries no terminal content. Ordered by {@code startedAt} descending in the * list response. */ -public record TerminalSessionView(String sessionId, long vmId, String vmName, long orgId, - String orgName, String workspaceName, long userId, String userEmail, String userName, +public record TerminalSessionView(String sessionId, UUID vmId, String vmName, UUID orgId, + String orgName, String workspaceName, UUID userId, String userEmail, String userName, String clientIp, Instant startedAt) { } diff --git a/src/main/java/kr/ac/pusan/pickle/user/AccountService.java b/src/main/java/kr/ac/pusan/pickle/user/AccountService.java index 4dddef38..e916449b 100644 --- a/src/main/java/kr/ac/pusan/pickle/user/AccountService.java +++ b/src/main/java/kr/ac/pusan/pickle/user/AccountService.java @@ -82,7 +82,7 @@ public MessageResponse withdraw(long userId, String password, String totpCode, S if (!passwordEncoder.matches(password, user.getPasswordHash())) { rateLimitService.registerLoginFailure(user.getEmail(), ip); auditService.record(user.getId(), user.getRole().name(), AuditService.ACCOUNT_WITHDRAW, - "user", user.getId(), Map.of("result", "mismatch"), ip); + "user", user.getPublicId(), Map.of("result", "mismatch"), ip); throw passwordMismatch(); } // 2FA-enrolled accounts must also present a valid TOTP or recovery code. @@ -121,7 +121,7 @@ public MessageResponse withdraw(long userId, String password, String totpCode, S UserStatus.WITHDRAWN, user.getId(), null)); auditService.recordAfterCommit(user.getId(), user.getRole().name(), AuditService.ACCOUNT_WITHDRAW, - "user", user.getId(), Map.of("email", user.getEmail()), ip); + "user", user.getPublicId(), Map.of("email", user.getEmail()), ip); notificationService.publish(user.getId(), NotificationEvent.ACCOUNT_WITHDRAWN, Map.of("userId", user.getId(), "userEmail", user.getEmail()), "account_withdrawn:" + user.getId()); diff --git a/src/main/java/kr/ac/pusan/pickle/user/MeController.java b/src/main/java/kr/ac/pusan/pickle/user/MeController.java index 047c6b6e..ca64cb57 100644 --- a/src/main/java/kr/ac/pusan/pickle/user/MeController.java +++ b/src/main/java/kr/ac/pusan/pickle/user/MeController.java @@ -1,11 +1,14 @@ package kr.ac.pusan.pickle.user; import kr.ac.pusan.pickle.common.error.ApiException; +import java.util.UUID; import kr.ac.pusan.pickle.common.error.ErrorCodes; import kr.ac.pusan.pickle.consent.TermsService; import kr.ac.pusan.pickle.workspace.WorkspaceMemberRepository; import kr.ac.pusan.pickle.mfa.MfaService; import kr.ac.pusan.pickle.security.AuthenticatedUser; +import kr.ac.pusan.pickle.orgs.Org; +import kr.ac.pusan.pickle.orgs.OrgRepository; import kr.ac.pusan.pickle.user.dto.UserProfileResponse; import org.springframework.http.HttpStatus; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -20,13 +23,16 @@ public class MeController { private final UserRepository userRepository; + private final OrgRepository orgRepository; private final WorkspaceMemberRepository workspaceMemberRepository; private final MfaService mfaService; private final TermsService termsService; - public MeController(UserRepository userRepository, WorkspaceMemberRepository workspaceMemberRepository, + public MeController(UserRepository userRepository, OrgRepository orgRepository, + WorkspaceMemberRepository workspaceMemberRepository, MfaService mfaService, TermsService termsService) { this.userRepository = userRepository; + this.orgRepository = orgRepository; this.workspaceMemberRepository = workspaceMemberRepository; this.mfaService = mfaService; this.termsService = termsService; @@ -38,7 +44,10 @@ public UserProfileResponse me(@AuthenticationPrincipal AuthenticatedUser princip User user = userRepository.findById(principal.id()) .orElseThrow(() -> new ApiException(HttpStatus.UNAUTHORIZED, ErrorCodes.AUTH_TOKEN_INVALID, "인증이 필요합니다", "액세스 토큰이 없거나 만료되었습니다. 토큰을 갱신한 뒤 다시 시도해 주세요.")); - return UserProfileResponse.from(user, workspaceMemberRepository.findWithWorkspaceByUserId(user.getId()), + UUID orgId = user.getOrgId() == null ? null + : orgRepository.findById(user.getOrgId()).map(Org::getPublicId).orElse(null); + return UserProfileResponse.from(user, orgId, + workspaceMemberRepository.findWithWorkspaceByUserId(user.getId()), mfaService.isEnrolled(user.getId()), termsService.pendingConsents(user.getId())); } } diff --git a/src/main/java/kr/ac/pusan/pickle/user/User.java b/src/main/java/kr/ac/pusan/pickle/user/User.java index f1af3169..8270e373 100644 --- a/src/main/java/kr/ac/pusan/pickle/user/User.java +++ b/src/main/java/kr/ac/pusan/pickle/user/User.java @@ -9,6 +9,7 @@ import jakarta.persistence.Id; import jakarta.persistence.Table; import java.time.Instant; +import java.util.UUID; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.annotations.UpdateTimestamp; @@ -22,6 +23,14 @@ public class User { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + /** + * The identifier this row wears outside the API boundary. Internal joins, + * sorts and foreign keys keep using {@link #id}. + */ + @JdbcTypeCode(SqlTypes.UUID) + @Column(name = "public_id", nullable = false, updatable = false, unique = true) + private UUID publicId = UUID.randomUUID(); + @Column(nullable = false, unique = true, columnDefinition = "citext") private String email; @@ -80,6 +89,10 @@ public Long getId() { return id; } + public UUID getPublicId() { + return publicId; + } + public String getEmail() { return email; } diff --git a/src/main/java/kr/ac/pusan/pickle/user/UserRepository.java b/src/main/java/kr/ac/pusan/pickle/user/UserRepository.java index 55e57cce..c53acc2c 100644 --- a/src/main/java/kr/ac/pusan/pickle/user/UserRepository.java +++ b/src/main/java/kr/ac/pusan/pickle/user/UserRepository.java @@ -2,10 +2,14 @@ import java.util.List; import java.util.Optional; +import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; public interface UserRepository extends JpaRepository { + /** Resolution of the identifier this row wears outside the API boundary. */ + Optional findByPublicId(UUID publicId); + /** Case-insensitive by virtue of the {@code citext} column. */ Optional findByEmail(String email); diff --git a/src/main/java/kr/ac/pusan/pickle/user/dto/UserProfileResponse.java b/src/main/java/kr/ac/pusan/pickle/user/dto/UserProfileResponse.java index 95ae40cb..89bf5a78 100644 --- a/src/main/java/kr/ac/pusan/pickle/user/dto/UserProfileResponse.java +++ b/src/main/java/kr/ac/pusan/pickle/user/dto/UserProfileResponse.java @@ -1,6 +1,7 @@ package kr.ac.pusan.pickle.user.dto; import java.util.List; +import java.util.UUID; import kr.ac.pusan.pickle.consent.dto.TermsVersionView; import kr.ac.pusan.pickle.workspace.WorkspaceKind; import kr.ac.pusan.pickle.workspace.WorkspaceMember; @@ -12,28 +13,29 @@ /** Contract schema {@code UserProfile}. */ public record UserProfileResponse( - Long id, + UUID id, String email, String name, UserRole role, - @Nullable Long orgId, + @Nullable UUID orgId, UserStatus status, List memberships, boolean mfaEnabled, List pendingConsents) { - public record Membership(Long workspaceId, String workspaceName, WorkspaceKind workspaceKind, WorkspaceMemberRole role) { + public record Membership(UUID workspaceId, String workspaceName, WorkspaceKind workspaceKind, WorkspaceMemberRole role) { public static Membership from(WorkspaceMember member) { - return new Membership(member.getWorkspace().getId(), member.getWorkspace().getName(), + return new Membership(member.getWorkspace().getPublicId(), member.getWorkspace().getName(), member.getWorkspace().getKind(), member.getRole()); } } - public static UserProfileResponse from(User user, List memberships, + public static UserProfileResponse from(User user, @Nullable UUID orgId, + List memberships, boolean mfaEnabled, List pendingConsents) { - return new UserProfileResponse(user.getId(), user.getEmail(), user.getName(), user.getRole(), - user.getOrgId(), user.getStatus(), + return new UserProfileResponse(user.getPublicId(), user.getEmail(), user.getName(), + user.getRole(), orgId, user.getStatus(), memberships.stream().map(Membership::from).toList(), mfaEnabled, pendingConsents); } } diff --git a/src/main/java/kr/ac/pusan/pickle/vm/AdminVmAccess.java b/src/main/java/kr/ac/pusan/pickle/vm/AdminVmAccess.java index 80649a14..921428f2 100644 --- a/src/main/java/kr/ac/pusan/pickle/vm/AdminVmAccess.java +++ b/src/main/java/kr/ac/pusan/pickle/vm/AdminVmAccess.java @@ -1,5 +1,6 @@ package kr.ac.pusan.pickle.vm; +import java.util.UUID; import kr.ac.pusan.pickle.common.error.ApiException; import kr.ac.pusan.pickle.common.error.ErrorCodes; import kr.ac.pusan.pickle.security.AuthenticatedUser; @@ -21,8 +22,8 @@ public AdminVmAccess(VmRepository vmRepository) { this.vmRepository = vmRepository; } - public Vm requireOrgScopedVm(AuthenticatedUser actor, long vmId) { - Vm vm = vmRepository.findById(vmId).orElseThrow(AdminVmAccess::vmNotFound); + public Vm requireOrgScopedVm(AuthenticatedUser actor, UUID vmId) { + Vm vm = vmRepository.findByPublicId(vmId).orElseThrow(AdminVmAccess::vmNotFound); if (actor.role().isOrgTier() && !vm.getOrgId().equals(actor.orgId())) { throw vmNotFound(); } diff --git a/src/main/java/kr/ac/pusan/pickle/vm/Vm.java b/src/main/java/kr/ac/pusan/pickle/vm/Vm.java index ef5fe7fc..5d66f2c1 100644 --- a/src/main/java/kr/ac/pusan/pickle/vm/Vm.java +++ b/src/main/java/kr/ac/pusan/pickle/vm/Vm.java @@ -10,6 +10,7 @@ import jakarta.persistence.Table; import java.time.Instant; import java.time.LocalDate; +import java.util.UUID; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.annotations.UpdateTimestamp; @@ -29,6 +30,14 @@ public class Vm { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + /** + * The identifier this row wears outside the API boundary. Internal joins, + * sorts and foreign keys keep using {@link #id}. + */ + @JdbcTypeCode(SqlTypes.UUID) + @Column(name = "public_id", nullable = false, updatable = false, unique = true) + private UUID publicId = UUID.randomUUID(); + @Column(name = "proxmox_vmid", unique = true) private Integer proxmoxVmid; @@ -199,6 +208,10 @@ public Long getId() { return id; } + public UUID getPublicId() { + return publicId; + } + public Integer getProxmoxVmid() { return proxmoxVmid; } diff --git a/src/main/java/kr/ac/pusan/pickle/vm/VmController.java b/src/main/java/kr/ac/pusan/pickle/vm/VmController.java index 030642b5..7f7d3a7f 100644 --- a/src/main/java/kr/ac/pusan/pickle/vm/VmController.java +++ b/src/main/java/kr/ac/pusan/pickle/vm/VmController.java @@ -8,6 +8,7 @@ import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import java.util.List; +import java.util.UUID; import kr.ac.pusan.pickle.auth.dto.MessageResponse; import kr.ac.pusan.pickle.common.web.PageResponse; import kr.ac.pusan.pickle.proxmox.RrdTimeframe; @@ -63,7 +64,7 @@ public VmController(VmQueryService vmQueryService, VmLifecycleService vmLifecycl @GetMapping public PageResponse listVms( @AuthenticationPrincipal AuthenticatedUser principal, - @RequestParam(required = false) Long workspaceId, + @RequestParam(required = false) UUID workspaceId, @RequestParam(defaultValue = "0") @Min(0) int page, @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) { return vmQueryService.list(principal, workspaceId, page, size); @@ -71,7 +72,7 @@ public PageResponse listVms( @GetMapping("/{vmId}") public VmDetailResponse getVm(@AuthenticationPrincipal AuthenticatedUser principal, - @PathVariable long vmId) { + @PathVariable UUID vmId) { return vmQueryService.get(principal, vmId); } @@ -79,7 +80,7 @@ public VmDetailResponse getVm(@AuthenticationPrincipal AuthenticatedUser princip @ResponseStatus(HttpStatus.ACCEPTED) @RequireReauth public VmDeletionResponse deleteVm( - @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable long vmId, + @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable UUID vmId, HttpServletRequest httpRequest) { return vmDeletionService.selfDelete(principal, vmId, clientIp(httpRequest)); } @@ -87,35 +88,35 @@ public VmDeletionResponse deleteVm( @PostMapping("/{vmId}/start") @ResponseStatus(HttpStatus.ACCEPTED) public MessageResponse startVm( - @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable long vmId) { + @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable UUID vmId) { return vmLifecycleService.start(principal, vmId); } @PostMapping("/{vmId}/shutdown") @ResponseStatus(HttpStatus.ACCEPTED) public MessageResponse shutdownVm( - @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable long vmId) { + @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable UUID vmId) { return vmLifecycleService.shutdown(principal, vmId); } @PostMapping("/{vmId}/reboot") @ResponseStatus(HttpStatus.ACCEPTED) public MessageResponse rebootVm( - @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable long vmId) { + @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable UUID vmId) { return vmLifecycleService.reboot(principal, vmId); } @PostMapping("/{vmId}/force-stop") @ResponseStatus(HttpStatus.ACCEPTED) public MessageResponse forceStopVm( - @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable long vmId) { + @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable UUID vmId) { return vmLifecycleService.forceStop(principal, vmId); } @GetMapping("/{vmId}/events") public PageResponse listVmEvents( @AuthenticationPrincipal AuthenticatedUser principal, - @PathVariable long vmId, + @PathVariable UUID vmId, @RequestParam(defaultValue = "0") @Min(0) int page, @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) { return vmQueryService.events(principal, vmId, page, size); @@ -124,7 +125,7 @@ public PageResponse listVmEvents( /** Live usage series from the hypervisor (contract v0.35.0); nothing is stored here. */ @GetMapping("/{vmId}/metrics") public VmMetricsResponse getVmMetrics(@AuthenticationPrincipal AuthenticatedUser principal, - @PathVariable long vmId, + @PathVariable UUID vmId, @Parameter(description = "조회 구간 — HOUR/DAY/WEEK/MONTH/YEAR " + "(해상도는 구간에 따라 거칠어짐)") @RequestParam(defaultValue = "HOUR") RrdTimeframe timeframe) { @@ -135,7 +136,7 @@ public VmMetricsResponse getVmMetrics(@AuthenticationPrincipal AuthenticatedUser @GetMapping("/{vmId}/password") @RequireReauth public ResponseEntity revealVmPassword( - @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable long vmId, + @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable UUID vmId, HttpServletRequest httpRequest) { VmPasswordResponse response = vmPasswordService.reveal(principal, vmId, clientIp(httpRequest)); @@ -148,7 +149,7 @@ public ResponseEntity revealVmPassword( @PostMapping("/{vmId}/password/regenerate") @RequireReauth public ResponseEntity regenerateVmPassword( - @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable long vmId, + @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable UUID vmId, HttpServletRequest httpRequest) { VmPasswordResponse response = vmPasswordService.regenerate(principal, vmId, clientIp(httpRequest)); @@ -160,7 +161,7 @@ public ResponseEntity regenerateVmPassword( /** Per-VM settings (EDITOR+; non-member 404). */ @GetMapping("/{vmId}/settings") public List getVmSettings(@AuthenticationPrincipal AuthenticatedUser principal, - @PathVariable long vmId) { + @PathVariable UUID vmId) { return vmSettingsService.get(principal, vmId); } @@ -168,7 +169,7 @@ public List getVmSettings(@AuthenticationPrincipal AuthenticatedU @PatchMapping("/{vmId}/settings") @RequireReauth public List updateVmSettings( - @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable long vmId, + @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable UUID vmId, @Valid @RequestBody VmSettingsUpdateRequest request, HttpServletRequest httpRequest) { return vmSettingsService.patch(principal, vmId, request.settings(), clientIp(httpRequest)); } diff --git a/src/main/java/kr/ac/pusan/pickle/vm/VmDeletionService.java b/src/main/java/kr/ac/pusan/pickle/vm/VmDeletionService.java index 30856c90..1b95e4d8 100644 --- a/src/main/java/kr/ac/pusan/pickle/vm/VmDeletionService.java +++ b/src/main/java/kr/ac/pusan/pickle/vm/VmDeletionService.java @@ -8,6 +8,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.UUID; import kr.ac.pusan.pickle.access.VmAccess; import kr.ac.pusan.pickle.access.VmAccessService; import kr.ac.pusan.pickle.admin.dto.ForceDeleteVmRequest; @@ -119,8 +120,9 @@ public VmDeletionService(VmRepository vmRepository, WorkspaceMemberRepository wo // ── self-delete (DELETE /vms/{vmId}) ─────────────────────────────────── @Transactional - public VmDeletionResponse selfDelete(AuthenticatedUser actor, long vmId, String ip) { - Vm vm = requireDeletableByActor(actor, vmId); + public VmDeletionResponse selfDelete(AuthenticatedUser actor, UUID publicVmId, String ip) { + Vm vm = requireDeletableByActor(actor, publicVmId); + long vmId = vm.getId(); requireNoPendingDeletion(vm); requireNotDeletionProtected(vmId); if (vm.getStatus() == VmStatus.ERROR) { @@ -139,14 +141,14 @@ public VmDeletionResponse selfDelete(AuthenticatedUser actor, long vmId, String vmEventRepository.save(new VmEvent(vmId, VmEventType.SELF_DELETE, actor.id(), "삭제 접수 — " + KST.format(scheduledFor) + " (KST) 파기 예정")); auditService.recordAfterCommit(actor.id(), actor.role().name(), AuditService.VM_SELF_DELETE, - "vm", vmId, Map.of("name", vm.getName(), "orgId", vm.getOrgId(), + "vm", vm.getPublicId(), Map.of("name", vm.getName(), "orgId", vm.getOrgId(), "workspaceId", vm.getWorkspaceId(), "scheduledFor", scheduledFor.toString()), ip); // Best-effort graceful shutdown; its failure never touches the schedule. enqueueAfterCommit(() -> deleteVmJob.gracefulShutdown(vmId)); notificationService.publish(recipients(vm, true), NotificationEvent.VM_DELETE_ACCEPTED, - Map.of("vmId", vmId, "vmName", vm.getName(), "scheduledFor", scheduledFor), null); - return new VmDeletionResponse(VmDeleteKind.SELF, scheduledFor, now, actor.id(), null, true); + Map.of("vmId", vm.getPublicId(), "vmName", vm.getName(), "scheduledFor", scheduledFor), null); + return new VmDeletionResponse(VmDeleteKind.SELF, scheduledFor, now, actor.publicId(), null, true); } /** ERROR VM: nothing to destroy — release the IP and finish immediately. */ @@ -176,17 +178,18 @@ private VmDeletionResponse deleteErrorVmImmediately(AuthenticatedUser actor, Vm vmEventRepository.save(new VmEvent(vm.getId(), VmEventType.DELETE, actor.id(), "VM 파기 완료 — ERROR 상태(파기할 게스트 없음), IP 회수")); auditService.recordAfterCommit(actor.id(), actor.role().name(), AuditService.VM_SELF_DELETE, - "vm", vm.getId(), Map.of("name", vm.getName(), "orgId", vm.getOrgId(), + "vm", vm.getPublicId(), Map.of("name", vm.getName(), "orgId", vm.getOrgId(), "workspaceId", vm.getWorkspaceId(), "immediate", true), ip); - return new VmDeletionResponse(VmDeleteKind.SELF, now, now, actor.id(), null, false); + return new VmDeletionResponse(VmDeleteKind.SELF, now, now, actor.publicId(), null, false); } // ── admin scheduled delete ───────────────────────────────────────────── @Transactional - public VmDeletionResponse scheduleDeletion(AuthenticatedUser actor, long vmId, + public VmDeletionResponse scheduleDeletion(AuthenticatedUser actor, UUID publicVmId, ScheduleVmDeletionRequest request, String ip) { - Vm vm = requireOrgScopedVm(actor, vmId); + Vm vm = requireOrgScopedVm(actor, publicVmId); + long vmId = vm.getId(); requireNoPendingDeletion(vm); // CREATING is deliberately accepted (unlike self-delete): the schedule // is intent-only, and the sweeper waits for a sweepable power state — @@ -208,21 +211,22 @@ public VmDeletionResponse scheduleDeletion(AuthenticatedUser actor, long vmId, vmEventRepository.save(new VmEvent(vmId, VmEventType.SCHEDULE_DELETE, actor.id(), "관리자 삭제 접수 — " + KST.format(request.scheduledFor()) + " (KST), 사유: " + reason)); auditService.recordAfterCommit(actor.id(), actor.role().name(), AuditService.VM_SCHEDULE_DELETE, - "vm", vmId, Map.of("name", vm.getName(), "orgId", vm.getOrgId(), + "vm", vm.getPublicId(), Map.of("name", vm.getName(), "orgId", vm.getOrgId(), "workspaceId", vm.getWorkspaceId(), "scheduledFor", request.scheduledFor().toString(), "reason", reason), ip); notificationService.publish(recipients(vm, false), NotificationEvent.VM_DELETE_SCHEDULED, - Map.of("vmId", vmId, "vmName", vm.getName(), "reason", reason, + Map.of("vmId", vm.getPublicId(), "vmName", vm.getName(), "reason", reason, "scheduledFor", request.scheduledFor()), null); - return new VmDeletionResponse(VmDeleteKind.ADMIN, request.scheduledFor(), now, actor.id(), + return new VmDeletionResponse(VmDeleteKind.ADMIN, request.scheduledFor(), now, actor.publicId(), reason, true); } // ── admin cancel (the only cancellation path — users have none) ───── @Transactional - public MessageResponse cancelScheduledDeletion(AuthenticatedUser actor, long vmId, String ip) { - Vm vm = requireOrgScopedVm(actor, vmId); + public MessageResponse cancelScheduledDeletion(AuthenticatedUser actor, UUID publicVmId, String ip) { + Vm vm = requireOrgScopedVm(actor, publicVmId); + long vmId = vm.getId(); Instant now = Instant.now(); if (vm.getDeleteKind() == VmDeleteKind.SELF) { // SELF: the VM entered DELETING at acceptance — cancel restores @@ -251,20 +255,21 @@ public MessageResponse cancelScheduledDeletion(AuthenticatedUser actor, long vmI ? "본인 삭제 취소 — VM은 STOPPED 상태로 유지" : "관리자 삭제 취소")); auditService.recordAfterCommit(actor.id(), actor.role().name(), - AuditService.VM_CANCEL_SCHEDULED_DELETE, "vm", vmId, + AuditService.VM_CANCEL_SCHEDULED_DELETE, "vm", vm.getPublicId(), Map.of("name", vm.getName(), "orgId", vm.getOrgId(), "workspaceId", vm.getWorkspaceId(), "canceledKind", vm.getDeleteKind().name()), ip); notificationService.publish(recipients(vm, false), NotificationEvent.VM_DELETE_CANCELED, - Map.of("vmId", vmId, "vmName", vm.getName()), null); + Map.of("vmId", vm.getPublicId(), "vmName", vm.getName()), null); return new MessageResponse("삭제가 취소되었습니다."); } // ── force delete (SYS_ADMIN, immediate, not cancelable) ──────────────── @Transactional - public MessageResponse forceDelete(AuthenticatedUser actor, long vmId, + public MessageResponse forceDelete(AuthenticatedUser actor, UUID publicVmId, ForceDeleteVmRequest request, String ip) { - Vm vm = vmRepository.findById(vmId).orElseThrow(VmAccessService::vmNotFound); + Vm vm = vmRepository.findByPublicId(publicVmId).orElseThrow(VmAccessService::vmNotFound); + long vmId = vm.getId(); if (!vm.getName().equals(request.confirmName())) { throw new ApiException(HttpStatus.CONFLICT, ErrorCodes.VM_CONFIRM_NAME_MISMATCH, "확인용 이름이 일치하지 않습니다", @@ -298,11 +303,11 @@ public MessageResponse forceDelete(AuthenticatedUser actor, long vmId, overrodeProtection ? "강제 삭제 접수 — 삭제 보호 오버라이드, 즉시 강제 종료 후 파기" : "강제 삭제 접수 — 즉시 강제 종료 후 파기")); auditService.recordAfterCommit(actor.id(), actor.role().name(), AuditService.VM_FORCE_DELETE, - "vm", vmId, Map.of("name", vm.getName(), "orgId", vm.getOrgId(), + "vm", vm.getPublicId(), Map.of("name", vm.getName(), "orgId", vm.getOrgId(), "workspaceId", vm.getWorkspaceId(), "overrodeProtection", overrodeProtection), ip); enqueueAfterCommit(() -> deleteVmJob.deleteVm(vmId)); notificationService.publish(recipients(vm, true), NotificationEvent.VM_DELETE_FORCE, - Map.of("vmId", vmId, "vmName", vm.getName()), null); + Map.of("vmId", vm.getPublicId(), "vmName", vm.getName()), null); return new MessageResponse("강제 삭제를 접수했습니다. VM이 즉시 강제 종료되고 파기됩니다."); } @@ -359,8 +364,8 @@ private void resumeParkedDeleteTask(long vmId) { * ORG_ADMIN of the VM's org, or SYS_ADMIN. Non-members and cross-org admins * get 404 (masking); anyone else who can see the VM gets 403. */ - private Vm requireDeletableByActor(AuthenticatedUser actor, long vmId) { - Vm vm = vmRepository.findById(vmId).orElseThrow(VmAccessService::vmNotFound); + private Vm requireDeletableByActor(AuthenticatedUser actor, UUID vmId) { + Vm vm = vmRepository.findByPublicId(vmId).orElseThrow(VmAccessService::vmNotFound); if (actor.role() == UserRole.SYS_ADMIN) { return vm; } @@ -381,8 +386,8 @@ private Vm requireDeletableByActor(AuthenticatedUser actor, long vmId) { } /** Admin-op scope: ORG_ADMIN sees only their own org's VMs (404 otherwise). */ - private Vm requireOrgScopedVm(AuthenticatedUser actor, long vmId) { - Vm vm = vmRepository.findById(vmId).orElseThrow(VmAccessService::vmNotFound); + private Vm requireOrgScopedVm(AuthenticatedUser actor, UUID vmId) { + Vm vm = vmRepository.findByPublicId(vmId).orElseThrow(VmAccessService::vmNotFound); if (actor.role() == UserRole.ORG_ADMIN && !vm.getOrgId().equals(actor.orgId())) { throw VmAccessService.vmNotFound(); } diff --git a/src/main/java/kr/ac/pusan/pickle/vm/VmEvent.java b/src/main/java/kr/ac/pusan/pickle/vm/VmEvent.java index 3d77349b..3c32fd73 100644 --- a/src/main/java/kr/ac/pusan/pickle/vm/VmEvent.java +++ b/src/main/java/kr/ac/pusan/pickle/vm/VmEvent.java @@ -9,6 +9,7 @@ import jakarta.persistence.Id; import jakarta.persistence.Table; import java.time.Instant; +import java.util.UUID; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.type.SqlTypes; @@ -26,6 +27,14 @@ public class VmEvent { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + /** + * The identifier this row wears outside the API boundary. Internal joins, + * sorts and foreign keys keep using {@link #id}. + */ + @JdbcTypeCode(SqlTypes.UUID) + @Column(name = "public_id", nullable = false, updatable = false, unique = true) + private UUID publicId = UUID.randomUUID(); + @Column(name = "vm_id", nullable = false) private Long vmId; @@ -57,6 +66,10 @@ public Long getId() { return id; } + public UUID getPublicId() { + return publicId; + } + public Long getVmId() { return vmId; } diff --git a/src/main/java/kr/ac/pusan/pickle/vm/VmEventRepository.java b/src/main/java/kr/ac/pusan/pickle/vm/VmEventRepository.java index 0290b69e..40632d17 100644 --- a/src/main/java/kr/ac/pusan/pickle/vm/VmEventRepository.java +++ b/src/main/java/kr/ac/pusan/pickle/vm/VmEventRepository.java @@ -1,11 +1,16 @@ package kr.ac.pusan.pickle.vm; import org.springframework.data.domain.Page; +import java.util.Optional; +import java.util.UUID; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; /** Append-only: only {@code save} and reads — no updates or deletes. */ public interface VmEventRepository extends JpaRepository { + /** Resolution of the identifier this row wears outside the API boundary. */ + Optional findByPublicId(UUID publicId); + Page findByVmIdOrderByIdDesc(Long vmId, Pageable pageable); } diff --git a/src/main/java/kr/ac/pusan/pickle/vm/VmLifecycleService.java b/src/main/java/kr/ac/pusan/pickle/vm/VmLifecycleService.java index 378eb936..1c326341 100644 --- a/src/main/java/kr/ac/pusan/pickle/vm/VmLifecycleService.java +++ b/src/main/java/kr/ac/pusan/pickle/vm/VmLifecycleService.java @@ -5,6 +5,7 @@ import java.util.Collection; import java.util.List; import java.util.Map; +import java.util.UUID; import kr.ac.pusan.pickle.access.ResourceRole; import kr.ac.pusan.pickle.access.VmAccess; import kr.ac.pusan.pickle.access.VmAccessService; @@ -84,8 +85,9 @@ public VmLifecycleService(VmRepository vmRepository, VmAccessService vmAccessSer } @Transactional - public MessageResponse start(AuthenticatedUser actor, long vmId) { + public MessageResponse start(AuthenticatedUser actor, UUID vmId) { Vm vm = requireMemberControllableVm(actor, vmId).vm(); + long id = vm.getId(); // Expiry guard: a past end date (KST, inclusive end) refuses start // even from STOPPED — only PATCH /admin/vms/{vmId}/period lifts it. if (vm.getEndDate() != null && vm.getEndDate().isBefore(ClockConfig.todayKst(clock))) { @@ -94,55 +96,59 @@ public MessageResponse start(AuthenticatedUser actor, long vmId) { "사용 기간(종료일 %s)이 만료되어 시작할 수 없습니다. 관리자에게 기간 연장을 요청해 주세요." .formatted(vm.getEndDate())); } - claimPowerAction(vmId, PowerAction.START, List.of(VmStatus.STOPPED), + claimPowerAction(id, PowerAction.START, List.of(VmStatus.STOPPED), "STOPPED 상태의 VM만 시작할 수 있습니다."); long actorId = actor.id(); - enqueueAfterCommit(() -> vmPowerJobs.start(vmId, actorId)); + enqueueAfterCommit(() -> vmPowerJobs.start(id, actorId)); return new MessageResponse("VM 시작 요청을 접수했습니다. 잠시 후 상태가 갱신됩니다."); } @Transactional - public MessageResponse shutdown(AuthenticatedUser actor, long vmId) { + public MessageResponse shutdown(AuthenticatedUser actor, UUID vmId) { Controllable controllable = requireMemberControllableVm(actor, vmId); - requireStopAllowed(vmId, controllable.role()); - claimPowerAction(vmId, PowerAction.SHUTDOWN, List.of(VmStatus.RUNNING), + long id = controllable.vm().getId(); + requireStopAllowed(id, controllable.role()); + claimPowerAction(id, PowerAction.SHUTDOWN, List.of(VmStatus.RUNNING), "RUNNING 상태의 VM만 종료할 수 있습니다."); long actorId = actor.id(); - enqueueAfterCommit(() -> vmPowerJobs.shutdown(vmId, actorId)); + enqueueAfterCommit(() -> vmPowerJobs.shutdown(id, actorId)); return new MessageResponse("VM 종료 요청을 접수했습니다. 잠시 후 상태가 갱신됩니다."); } @Transactional - public MessageResponse reboot(AuthenticatedUser actor, long vmId) { + public MessageResponse reboot(AuthenticatedUser actor, UUID vmId) { Controllable controllable = requireMemberControllableVm(actor, vmId); - requireStopAllowed(vmId, controllable.role()); + long id = controllable.vm().getId(); + requireStopAllowed(id, controllable.role()); // Intent is visible immediately (and force-stop can target a hung // reboot); the CAS loses to a concurrent transition OR a live claim → 409. - if (vmRepository.claimReboot(vmId, VmStatus.RUNNING, VmStatus.REBOOTING, Instant.now()) == 0) { - throw powerConflict(vmId, "RUNNING 상태의 VM만 재부팅할 수 있습니다."); + if (vmRepository.claimReboot(id, VmStatus.RUNNING, VmStatus.REBOOTING, Instant.now()) == 0) { + throw powerConflict(id, "RUNNING 상태의 VM만 재부팅할 수 있습니다."); } long actorId = actor.id(); - enqueueAfterCommit(() -> vmPowerJobs.reboot(vmId, actorId)); + enqueueAfterCommit(() -> vmPowerJobs.reboot(id, actorId)); return new MessageResponse("VM 재부팅 요청을 접수했습니다. 잠시 후 상태가 갱신됩니다."); } @Transactional - public MessageResponse forceStop(AuthenticatedUser actor, long vmId) { + public MessageResponse forceStop(AuthenticatedUser actor, UUID vmId) { Controllable controllable = requireMemberControllableVm(actor, vmId); - requireStopAllowed(vmId, controllable.role()); - claimPowerAction(vmId, PowerAction.FORCE_STOP, + long id = controllable.vm().getId(); + requireStopAllowed(id, controllable.role()); + claimPowerAction(id, PowerAction.FORCE_STOP, List.of(VmStatus.RUNNING, VmStatus.REBOOTING), "RUNNING 또는 REBOOTING 상태의 VM만 강제 종료할 수 있습니다."); long actorId = actor.id(); - enqueueAfterCommit(() -> vmPowerJobs.forceStop(vmId, actorId)); + enqueueAfterCommit(() -> vmPowerJobs.forceStop(id, actorId)); return new MessageResponse("VM 강제 종료 요청을 접수했습니다. 잠시 후 상태가 갱신됩니다."); } /* ─── admin intervention (org-scoped, stop-protection bypass, audited) ─── */ @Transactional - public MessageResponse adminStart(AuthenticatedUser actor, long vmId, String ip) { + public MessageResponse adminStart(AuthenticatedUser actor, UUID vmId, String ip) { Vm vm = adminVmAccess.requireOrgScopedVm(actor, vmId); + long id = vm.getId(); // Same expiry guard as the member path: extend the period first. if (vm.getEndDate() != null && vm.getEndDate().isBefore(ClockConfig.todayKst(clock))) { throw new ApiException(HttpStatus.CONFLICT, ErrorCodes.VM_EXPIRED, @@ -150,46 +156,49 @@ public MessageResponse adminStart(AuthenticatedUser actor, long vmId, String ip) "사용 기간(종료일 %s)이 만료되어 시작할 수 없습니다. 먼저 기간을 연장해 주세요." .formatted(vm.getEndDate())); } - claimPowerAction(vmId, PowerAction.START, List.of(VmStatus.STOPPED), + claimPowerAction(id, PowerAction.START, List.of(VmStatus.STOPPED), "STOPPED 상태의 VM만 시작할 수 있습니다."); recordAdminPowerAudit(actor, vm, AuditService.VM_ADMIN_START, ip); long actorId = actor.id(); - enqueueAfterCommit(() -> vmPowerJobs.start(vmId, actorId)); + enqueueAfterCommit(() -> vmPowerJobs.start(id, actorId)); return new MessageResponse("VM 시작 요청을 접수했습니다. 잠시 후 상태가 갱신됩니다."); } @Transactional - public MessageResponse adminShutdown(AuthenticatedUser actor, long vmId, String ip) { + public MessageResponse adminShutdown(AuthenticatedUser actor, UUID vmId, String ip) { Vm vm = adminVmAccess.requireOrgScopedVm(actor, vmId); - claimPowerAction(vmId, PowerAction.SHUTDOWN, List.of(VmStatus.RUNNING), + long id = vm.getId(); + claimPowerAction(id, PowerAction.SHUTDOWN, List.of(VmStatus.RUNNING), "RUNNING 상태의 VM만 종료할 수 있습니다."); recordAdminPowerAudit(actor, vm, AuditService.VM_ADMIN_SHUTDOWN, ip); long actorId = actor.id(); - enqueueAfterCommit(() -> vmPowerJobs.shutdown(vmId, actorId)); + enqueueAfterCommit(() -> vmPowerJobs.shutdown(id, actorId)); return new MessageResponse("VM 종료 요청을 접수했습니다. 잠시 후 상태가 갱신됩니다."); } @Transactional - public MessageResponse adminReboot(AuthenticatedUser actor, long vmId, String ip) { + public MessageResponse adminReboot(AuthenticatedUser actor, UUID vmId, String ip) { Vm vm = adminVmAccess.requireOrgScopedVm(actor, vmId); - if (vmRepository.claimReboot(vmId, VmStatus.RUNNING, VmStatus.REBOOTING, Instant.now()) == 0) { - throw powerConflict(vmId, "RUNNING 상태의 VM만 재부팅할 수 있습니다."); + long id = vm.getId(); + if (vmRepository.claimReboot(id, VmStatus.RUNNING, VmStatus.REBOOTING, Instant.now()) == 0) { + throw powerConflict(id, "RUNNING 상태의 VM만 재부팅할 수 있습니다."); } recordAdminPowerAudit(actor, vm, AuditService.VM_ADMIN_REBOOT, ip); long actorId = actor.id(); - enqueueAfterCommit(() -> vmPowerJobs.reboot(vmId, actorId)); + enqueueAfterCommit(() -> vmPowerJobs.reboot(id, actorId)); return new MessageResponse("VM 재부팅 요청을 접수했습니다. 잠시 후 상태가 갱신됩니다."); } @Transactional - public MessageResponse adminForceStop(AuthenticatedUser actor, long vmId, String ip) { + public MessageResponse adminForceStop(AuthenticatedUser actor, UUID vmId, String ip) { Vm vm = adminVmAccess.requireOrgScopedVm(actor, vmId); - claimPowerAction(vmId, PowerAction.FORCE_STOP, + long id = vm.getId(); + claimPowerAction(id, PowerAction.FORCE_STOP, List.of(VmStatus.RUNNING, VmStatus.REBOOTING), "RUNNING 또는 REBOOTING 상태의 VM만 강제 종료할 수 있습니다."); recordAdminPowerAudit(actor, vm, AuditService.VM_ADMIN_FORCE_STOP, ip); long actorId = actor.id(); - enqueueAfterCommit(() -> vmPowerJobs.forceStop(vmId, actorId)); + enqueueAfterCommit(() -> vmPowerJobs.forceStop(id, actorId)); return new MessageResponse("VM 강제 종료 요청을 접수했습니다. 잠시 후 상태가 갱신됩니다."); } @@ -199,7 +208,7 @@ public MessageResponse adminForceStop(AuthenticatedUser actor, long vmId, String * member path). Reads are not audited, matching the other admin surfaces. */ private void recordAdminPowerAudit(AuthenticatedUser actor, Vm vm, String action, String ip) { - auditService.recordAfterCommit(actor.id(), actor.role().name(), action, "vm", vm.getId(), + auditService.recordAfterCommit(actor.id(), actor.role().name(), action, "vm", vm.getPublicId(), Map.of("fromStatus", vm.getStatus().name()), ip); } @@ -236,7 +245,7 @@ private record Controllable(Vm vm, ResourceRole role) { * 404 (masking), a member below MEMBER answers 403. The role is returned so * stop-protected ops can additionally require EDITOR. */ - private Controllable requireMemberControllableVm(AuthenticatedUser actor, long vmId) { + private Controllable requireMemberControllableVm(AuthenticatedUser actor, UUID vmId) { VmAccess access = vmAccessService.of(actor, vmId); Vm vm = access.requireAtLeast(ResourceRole.MEMBER, "VM을 제어할 권한이 없습니다", "이 VM의 참여자 이상만 VM 전원을 제어할 수 있습니다."); diff --git a/src/main/java/kr/ac/pusan/pickle/vm/VmMetricsService.java b/src/main/java/kr/ac/pusan/pickle/vm/VmMetricsService.java index f1c17c1a..b4e770a0 100644 --- a/src/main/java/kr/ac/pusan/pickle/vm/VmMetricsService.java +++ b/src/main/java/kr/ac/pusan/pickle/vm/VmMetricsService.java @@ -2,6 +2,7 @@ import java.time.Clock; import java.util.List; +import java.util.UUID; import kr.ac.pusan.pickle.access.VmAccessService; import kr.ac.pusan.pickle.common.error.ApiException; import kr.ac.pusan.pickle.common.error.ErrorCodes; @@ -59,7 +60,7 @@ public VmMetricsService(VmAccessService vmAccessService, NodeRepository nodeRepo this.clock = clock; } - public VmMetricsResponse metrics(AuthenticatedUser actor, long vmId, RrdTimeframe timeframe) { + public VmMetricsResponse metrics(AuthenticatedUser actor, UUID vmId, RrdTimeframe timeframe) { Vm vm = vmAccessService.of(actor, vmId).requireVisible(); if (vm.getProxmoxVmid() == null || vm.getStatus() == VmStatus.DELETED) { return new VmMetricsResponse(timeframe.name(), clock.instant(), false, NOT_PROVISIONED, diff --git a/src/main/java/kr/ac/pusan/pickle/vm/VmPasswordService.java b/src/main/java/kr/ac/pusan/pickle/vm/VmPasswordService.java index 76fdeeff..0711f8ad 100644 --- a/src/main/java/kr/ac/pusan/pickle/vm/VmPasswordService.java +++ b/src/main/java/kr/ac/pusan/pickle/vm/VmPasswordService.java @@ -3,6 +3,7 @@ import java.time.Instant; import java.util.Map; import java.util.Set; +import java.util.UUID; import kr.ac.pusan.pickle.access.ResourceRole; import kr.ac.pusan.pickle.access.VmAccess; import kr.ac.pusan.pickle.access.VmAccessService; @@ -78,11 +79,12 @@ public VmPasswordService(VmRepository vmRepository, /** Reveals the stored password; min role is per-VM {@code password_reveal_min_role}. */ @Transactional - public VmPasswordResponse reveal(AuthenticatedUser actor, long vmId, String ip) { + public VmPasswordResponse reveal(AuthenticatedUser actor, UUID vmId, String ip) { MemberVm memberVm = requireMemberVm(actor, vmId); Vm vm = memberVm.vm(); + long id = vm.getId(); ResourceRole minRole = - vmSettingsService.role(vmId, VmSettingsService.PASSWORD_REVEAL_MIN_ROLE); + vmSettingsService.role(id, VmSettingsService.PASSWORD_REVEAL_MIN_ROLE); if (!memberVm.role().atLeast(minRole)) { throw new ApiException(HttpStatus.FORBIDDEN, ErrorCodes.WORKSPACE_ROLE_INSUFFICIENT, "비밀번호를 열람할 권한이 없습니다", @@ -101,9 +103,9 @@ public VmPasswordResponse reveal(AuthenticatedUser actor, long vmId, String ip) "저장된 비밀번호가 없습니다. 비밀번호 재생성으로 새 비밀번호를 만들 수 있습니다."); } String password = credentialCipher.decrypt(vm.getPasswordEnc()); - vmRepository.recordPasswordViewed(vmId, Instant.now()); + vmRepository.recordPasswordViewed(id, Instant.now()); auditService.record(actor.id(), actor.role().name(), AuditService.VM_PASSWORD_REVEAL, - "vm", vmId, Map.of(), ip); + "vm", vm.getPublicId(), Map.of(), ip); return new VmPasswordResponse(password, vm.getSshUsername(), sshHost, sshPort); } @@ -114,7 +116,7 @@ public VmPasswordResponse reveal(AuthenticatedUser actor, long vmId, String ip) * the value; generation is always the platform's. */ @Transactional - public VmPasswordResponse regenerate(AuthenticatedUser actor, long vmId, String ip) { + public VmPasswordResponse regenerate(AuthenticatedUser actor, UUID vmId, String ip) { MemberVm memberVm = requireMemberVm(actor, vmId); Vm vm = memberVm.vm(); if (!memberVm.role().atLeast(ResourceRole.EDITOR)) { @@ -135,10 +137,10 @@ public VmPasswordResponse regenerate(AuthenticatedUser actor, long vmId, String if (!applied) { throw agentUnavailable(); } - vmRepository.storeCredentials(vmId, credentialCipher.encrypt(password), + vmRepository.storeCredentials(vm.getId(), credentialCipher.encrypt(password), passwordEncoder.encode(password), Instant.now()); auditService.record(actor.id(), actor.role().name(), AuditService.VM_PASSWORD_REGENERATE, - "vm", vmId, Map.of(), ip); + "vm", vm.getPublicId(), Map.of(), ip); return new VmPasswordResponse(password, vm.getSshUsername(), sshHost, sshPort); } @@ -146,7 +148,7 @@ private record MemberVm(Vm vm, ResourceRole role) { } /** Non-member answers 404 (masking); returns the VM and the actor's role. */ - private MemberVm requireMemberVm(AuthenticatedUser actor, long vmId) { + private MemberVm requireMemberVm(AuthenticatedUser actor, UUID vmId) { VmAccess access = vmAccessService.of(actor, vmId); return new MemberVm(access.requireVisible(), access.role()); } diff --git a/src/main/java/kr/ac/pusan/pickle/vm/VmQueryService.java b/src/main/java/kr/ac/pusan/pickle/vm/VmQueryService.java index bd4cbd84..99ffff45 100644 --- a/src/main/java/kr/ac/pusan/pickle/vm/VmQueryService.java +++ b/src/main/java/kr/ac/pusan/pickle/vm/VmQueryService.java @@ -1,6 +1,7 @@ package kr.ac.pusan.pickle.vm; import java.util.List; +import java.util.UUID; import java.util.Map; import java.util.Set; import java.util.stream.Collectors; @@ -20,6 +21,8 @@ import kr.ac.pusan.pickle.ipam.IpAddressResolver; import kr.ac.pusan.pickle.orgs.Org; import kr.ac.pusan.pickle.orgs.OrgRepository; +import kr.ac.pusan.pickle.inventory.OsImage; +import kr.ac.pusan.pickle.inventory.OsImageRepository; import kr.ac.pusan.pickle.provisioning.ProvisioningTaskRepository; import kr.ac.pusan.pickle.provisioning.ProvisioningTaskStatus; import kr.ac.pusan.pickle.publishing.Domain; @@ -27,12 +30,15 @@ import kr.ac.pusan.pickle.publishing.DomainStatus; import kr.ac.pusan.pickle.publishing.PublicationAssembler; import kr.ac.pusan.pickle.publishing.dto.PublicationView; +import kr.ac.pusan.pickle.request.Request; +import kr.ac.pusan.pickle.request.RequestRepository; import kr.ac.pusan.pickle.security.AuthenticatedUser; import kr.ac.pusan.pickle.user.User; import kr.ac.pusan.pickle.user.UserRepository; import kr.ac.pusan.pickle.vm.dto.ProvisioningTaskResponse; import kr.ac.pusan.pickle.vm.dto.VmDetailResponse; import kr.ac.pusan.pickle.vm.dto.VmEventResponse; +import kr.ac.pusan.pickle.vm.dto.VmReferences; import kr.ac.pusan.pickle.vm.dto.VmSummaryResponse; import kr.ac.pusan.pickle.vmsettings.VmSettingsService; import org.springframework.beans.factory.annotation.Value; @@ -68,6 +74,8 @@ public class VmQueryService { private final UserRepository userRepository; private final WorkspaceRepository workspaceRepository; private final OrgRepository orgRepository; + private final OsImageRepository osImageRepository; + private final RequestRepository requestRepository; private final IpAddressResolver ipAddressResolver; private final ProvisioningTaskRepository provisioningTaskRepository; private final VmEventRepository vmEventRepository; @@ -81,6 +89,7 @@ public VmQueryService(VmRepository vmRepository, WorkspaceMemberRepository works ResourceAccessGrantRepository grantRepository, UserRepository userRepository, WorkspaceRepository workspaceRepository, OrgRepository orgRepository, + OsImageRepository osImageRepository, RequestRepository requestRepository, IpAddressResolver ipAddressResolver, ProvisioningTaskRepository provisioningTaskRepository, VmEventRepository vmEventRepository, @@ -94,6 +103,8 @@ public VmQueryService(VmRepository vmRepository, WorkspaceMemberRepository works this.userRepository = userRepository; this.workspaceRepository = workspaceRepository; this.orgRepository = orgRepository; + this.osImageRepository = osImageRepository; + this.requestRepository = requestRepository; this.ipAddressResolver = ipAddressResolver; this.provisioningTaskRepository = provisioningTaskRepository; this.vmEventRepository = vmEventRepository; @@ -104,7 +115,7 @@ public VmQueryService(VmRepository vmRepository, WorkspaceMemberRepository works } @Transactional(readOnly = true) - public PageResponse list(AuthenticatedUser actor, Long workspaceId, int page, int size) { + public PageResponse list(AuthenticatedUser actor, UUID workspaceId, int page, int size) { Page result = listPage(actor, workspaceId, PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "id"))); return PageResponse.of(result.getContent(), result); @@ -115,15 +126,20 @@ public PageResponse list(AuthenticatedUser actor, Long worksp * that one set of visibility rules serves both surfaces. */ @Transactional(readOnly = true) - public Page listPage(AuthenticatedUser actor, Long workspaceId, Pageable pageable) { + public Page listPage(AuthenticatedUser actor, UUID workspaceId, Pageable pageable) { List memberships = workspaceMemberRepository.findWithWorkspaceByUserId(actor.id()); - Map workspaceNames = memberships.stream() - .collect(Collectors.toMap(m -> m.getWorkspace().getId(), m -> m.getWorkspace().getName())); - List workspaceIds = List.copyOf(workspaceNames.keySet()); + Map workspaces = memberships.stream() + .collect(Collectors.toMap(m -> m.getWorkspace().getId(), WorkspaceMember::getWorkspace, + (first, second) -> first)); + List workspaceIds = List.copyOf(workspaces.keySet()); Page result; if (workspaceId != null) { - result = workspaceIds.contains(workspaceId) - ? vmRepository.findByWorkspaceId(workspaceId, pageable) + // An unknown workspace id and one outside my memberships answer the + // same empty page: the contract defines no 403 for the list. + Long filterId = workspaceRepository.findByPublicId(workspaceId) + .map(Workspace::getId).orElse(null); + result = filterId != null && workspaceIds.contains(filterId) + ? vmRepository.findByWorkspaceId(filterId, pageable) : Page.empty(pageable); } else { result = workspaceIds.isEmpty() @@ -132,6 +148,7 @@ public Page listPage(AuthenticatedUser actor, Long workspaceI } List vms = result.getContent(); Map orgNames = orgNames(vms); + Map requestIds = requestPublicIds(vms); Map displayNames = vmSettingsService.displayNames( vms.stream().map(Vm::getId).toList()); Set ownedWorkspaceIds = memberships.stream() @@ -141,17 +158,20 @@ public Page listPage(AuthenticatedUser actor, Long workspaceI VmListAccess access = listAccess(actor.id(), vms); return new PageImpl<>(vms.stream() .map(vm -> { - String workspaceName = workspaceNames.getOrDefault(vm.getWorkspaceId(), ""); + Workspace workspace = workspaces.get(vm.getWorkspaceId()); + UUID workspacePublicId = workspace == null ? null : workspace.getPublicId(); + String workspaceName = workspace == null ? "" : workspace.getName(); String displayName = displayNames.get(vm.getId()); // Only a grant opens the row. A workspace owner without one gets // the same restricted row as anyone else, plus the flag that // lets the console offer them the access list — the way back // in for a VM whose own owner is gone. if (access.reachable().contains(vm.getId())) { - return VmSummaryResponse.from(vm, workspaceName, orgNames.get(vm.getOrgId()), - displayName); + return VmSummaryResponse.from(vm, workspacePublicId, workspaceName, + orgNames.get(vm.getOrgId()), displayName, + requestIds.get(vm.getRequestId())); } - return VmSummaryResponse.restricted(vm, workspaceName, displayName, + return VmSummaryResponse.restricted(vm, workspacePublicId, workspaceName, displayName, access.ownerNames().getOrDefault(vm.getId(), List.of()), ownedWorkspaceIds.contains(vm.getWorkspaceId())); }) @@ -189,6 +209,22 @@ private VmListAccess listAccess(long userId, List vms) { return new VmListAccess(reachable, ownerNames); } + /** + * Batch request-reference join for the summary views. The summary reports + * which request produced the VM and had no join for it while the id was + * the VM's own column; a public id lives on the request row, so it needs + * one. Shared with the admin list so both pages pay for it once. + */ + public Map requestPublicIds(List vms) { + List requestIds = vms.stream().map(Vm::getRequestId) + .filter(java.util.Objects::nonNull).distinct().toList(); + if (requestIds.isEmpty()) { + return Map.of(); + } + return requestRepository.findAllById(requestIds).stream() + .collect(Collectors.toMap(Request::getId, Request::getPublicId)); + } + /** Batch org-name join for the summary views (avoids N+1). */ private Map orgNames(List vms) { List orgIds = vms.stream().map(Vm::getOrgId).filter(java.util.Objects::nonNull) @@ -201,7 +237,7 @@ private Map orgNames(List vms) { } @Transactional(readOnly = true) - public VmDetailResponse get(AuthenticatedUser actor, long vmId) { + public VmDetailResponse get(AuthenticatedUser actor, UUID vmId) { VmAccess access = vmAccessService.of(actor, vmId); return detailOf(access.requireVisible(), access.role(), access.manages()); } @@ -226,10 +262,20 @@ public VmDetailResponse detailOf(Vm vm, ResourceRole myResourceRole, long vmId = vm.getId(); // History-preserving joins: a DELETED vm's workspace/org may have been // deleted afterwards, so this deliberately reads all workspaces/orgs. - String workspaceName = workspaceRepository.findById(vm.getWorkspaceId()) - .map(Workspace::getName).orElse(""); - String orgName = vm.getOrgId() == null ? null - : orgRepository.findById(vm.getOrgId()).map(Org::getName).orElse(null); + Workspace workspace = workspaceRepository.findById(vm.getWorkspaceId()).orElse(null); + String workspaceName = workspace == null ? "" : workspace.getName(); + Org org = vm.getOrgId() == null ? null + : orgRepository.findById(vm.getOrgId()).orElse(null); + String orgName = org == null ? null : org.getName(); + VmReferences refs = new VmReferences( + workspace == null ? null : workspace.getPublicId(), + org == null ? null : org.getPublicId(), + vm.getImageId() == null ? null + : osImageRepository.findById(vm.getImageId()).map(OsImage::getPublicId).orElse(null), + vm.getRequestId() == null ? null + : requestRepository.findById(vm.getRequestId()).map(Request::getPublicId).orElse(null), + vm.getDeleteRequestedBy() == null ? null + : userRepository.findById(vm.getDeleteRequestedBy()).map(User::getPublicId).orElse(null)); String displayName = vmSettingsService.string(vmId, VmSettingsService.DISPLAY_NAME); String ipAddress = liveIpAddress(vm); ProvisioningTaskResponse provisioning = provisioningTaskRepository @@ -245,20 +291,19 @@ public VmDetailResponse detailOf(Vm vm, ResourceRole myResourceRole, .filter(domain -> domain.getStatus() != DomainStatus.REMOVED) .filter(publicationAssembler::hasLiveRoute) .sorted(java.util.Comparator.comparing(Domain::getId)) - .map(publicationAssembler::toPublication) + .map(domain -> publicationAssembler.toPublication(domain, vm.getPublicId())) .toList(); boolean passwordRevealAllowed = myResourceRole != null && myResourceRole.atLeast( vmSettingsService.role(vmId, VmSettingsService.PASSWORD_REVEAL_MIN_ROLE)); - return VmDetailResponse.from(vm, workspaceName, orgName, displayName, ipAddress, sshHost, + return VmDetailResponse.from(vm, refs, workspaceName, orgName, displayName, ipAddress, sshHost, myResourceRole, passwordRevealAllowed, accessManageAllowed, provisioning, publications); } /** Newest-first lifecycle history (contract op {@code listVmEvents}). */ @Transactional(readOnly = true) - public PageResponse events(AuthenticatedUser actor, long vmId, int page, int size) { - requireVisibleVm(actor, vmId); - return eventsOf(vmId, page, size); + public PageResponse events(AuthenticatedUser actor, UUID vmId, int page, int size) { + return eventsOf(requireVisibleVm(actor, vmId).getId(), page, size); } /** @@ -269,8 +314,12 @@ public PageResponse events(AuthenticatedUser actor, long vmId, public PageResponse eventsOf(long vmId, int page, int size) { Page result = vmEventRepository.findByVmIdOrderByIdDesc(vmId, PageRequest.of(page, size)); - return PageResponse.of(result.getContent().stream().map(VmEventResponse::from).toList(), - result); + Map actorIds = userRepository.findAllById(result.getContent().stream() + .map(VmEvent::getActorId).filter(java.util.Objects::nonNull).distinct().toList()) + .stream().collect(Collectors.toMap(User::getId, User::getPublicId)); + return PageResponse.of(result.getContent().stream() + .map(event -> VmEventResponse.from(event, actorIds.get(event.getActorId()))) + .toList(), result); } /** @@ -278,7 +327,7 @@ public PageResponse eventsOf(long vmId, int page, int size) { * non-member cannot probe which VM ids exist (masking, contract v0.3.2 — * consistent with the power/delete paths). */ - private Vm requireVisibleVm(AuthenticatedUser actor, long vmId) { + private Vm requireVisibleVm(AuthenticatedUser actor, UUID vmId) { return vmAccessService.of(actor, vmId).requireVisible(); } diff --git a/src/main/java/kr/ac/pusan/pickle/vm/VmRepository.java b/src/main/java/kr/ac/pusan/pickle/vm/VmRepository.java index 037622ba..9c6a2fa1 100644 --- a/src/main/java/kr/ac/pusan/pickle/vm/VmRepository.java +++ b/src/main/java/kr/ac/pusan/pickle/vm/VmRepository.java @@ -3,6 +3,8 @@ import java.time.Instant; import java.util.Collection; import java.util.List; +import java.util.Optional; +import java.util.UUID; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; @@ -14,6 +16,9 @@ public interface VmRepository extends JpaRepository, JpaSpecificationExecutor { + /** Resolution of the identifier this row wears outside the API boundary. */ + Optional findByPublicId(UUID publicId); + boolean existsByHostname(String hostname); /** diff --git a/src/main/java/kr/ac/pusan/pickle/vm/dto/VmDeletionResponse.java b/src/main/java/kr/ac/pusan/pickle/vm/dto/VmDeletionResponse.java index 0ff8b5e5..804930af 100644 --- a/src/main/java/kr/ac/pusan/pickle/vm/dto/VmDeletionResponse.java +++ b/src/main/java/kr/ac/pusan/pickle/vm/dto/VmDeletionResponse.java @@ -1,6 +1,7 @@ package kr.ac.pusan.pickle.vm.dto; import java.time.Instant; +import java.util.UUID; import kr.ac.pusan.pickle.vm.Vm; import kr.ac.pusan.pickle.vm.VmDeleteKind; import kr.ac.pusan.pickle.vm.VmStatus; @@ -16,12 +17,12 @@ public record VmDeletionResponse( VmDeleteKind kind, Instant scheduledFor, Instant requestedAt, - Long requestedById, + @Nullable UUID requestedById, @Nullable String reason, boolean cancelable) { /** Maps the {@code delete_*} columns; null when no deletion is pending. */ - public static VmDeletionResponse from(Vm vm) { + public static VmDeletionResponse from(Vm vm, UUID requestedById) { if (vm.getDeleteKind() == null) { return null; } @@ -30,7 +31,7 @@ public static VmDeletionResponse from(Vm vm) { && vm.getDeleteScheduledFor() != null && vm.getDeleteScheduledFor().isAfter(Instant.now()); return new VmDeletionResponse(vm.getDeleteKind(), vm.getDeleteScheduledFor(), - vm.getDeleteRequestedAt(), vm.getDeleteRequestedBy(), vm.getDeleteReason(), + vm.getDeleteRequestedAt(), requestedById, vm.getDeleteReason(), cancelable); } } diff --git a/src/main/java/kr/ac/pusan/pickle/vm/dto/VmDetailResponse.java b/src/main/java/kr/ac/pusan/pickle/vm/dto/VmDetailResponse.java index 6b3f84ad..1707d116 100644 --- a/src/main/java/kr/ac/pusan/pickle/vm/dto/VmDetailResponse.java +++ b/src/main/java/kr/ac/pusan/pickle/vm/dto/VmDetailResponse.java @@ -4,6 +4,7 @@ import java.time.Instant; import java.time.LocalDate; import java.util.List; +import java.util.UUID; import kr.ac.pusan.pickle.access.ResourceRole; import kr.ac.pusan.pickle.publishing.dto.PublicationView; import kr.ac.pusan.pickle.vm.Vm; @@ -19,23 +20,27 @@ * VM is unpublished. */ public record VmDetailResponse( - Long id, + UUID id, String name, String hostname, VmStatus status, int vcpu, int memoryMb, int diskGb, - Long workspaceId, + @Schema(description = "소유 워크스페이스. 행이 사라진 경우에만 null입니다.") + @Nullable UUID workspaceId, String workspaceName, @Nullable String orgName, @Nullable String displayName, - Long requestId, + @Schema(description = "이 VM을 만든 신청. 행이 사라진 경우에만 null입니다.") + @Nullable UUID requestId, @Nullable String statusDetail, boolean sshGatewayBlocked, Instant createdAt, - Long orgId, - Long imageId, + @Schema(description = "소속 기관. 행이 사라진 경우에만 null입니다.") + @Nullable UUID orgId, + @Schema(description = "생성에 쓰인 OS 이미지. 행이 사라진 경우에만 null입니다.") + @Nullable UUID imageId, @Nullable String ipAddress, String sshUsername, String sshHost, @@ -67,7 +72,8 @@ public record VmDetailResponse( * Admin surfaces pass {@code myResourceRole} null and get them all false — * their own authorization is org-scoped and lives elsewhere. */ - public static VmDetailResponse from(Vm vm, String workspaceName, String orgName, String displayName, + public static VmDetailResponse from(Vm vm, VmReferences refs, String workspaceName, + String orgName, String displayName, String ipAddress, String sshHost, ResourceRole myResourceRole, boolean passwordRevealAllowed, boolean accessManageAllowed, ProvisioningTaskResponse provisioning, List publications) { @@ -75,13 +81,13 @@ public static VmDetailResponse from(Vm vm, String workspaceName, String orgName, && myResourceRole.atLeast(ResourceRole.MEMBER); boolean atLeastEditor = myResourceRole != null && myResourceRole.atLeast(ResourceRole.EDITOR); - return new VmDetailResponse(vm.getId(), vm.getName(), vm.getHostname(), vm.getStatus(), - vm.getVcpu(), vm.getMemoryMb(), vm.getDiskGb(), vm.getWorkspaceId(), workspaceName, orgName, - displayName, vm.getRequestId(), vm.getStatusDetail(), vm.isSshGatewayBlocked(), - vm.getCreatedAt(), vm.getOrgId(), - vm.getImageId(), ipAddress, vm.getSshUsername(), sshHost, myResourceRole, + return new VmDetailResponse(vm.getPublicId(), vm.getName(), vm.getHostname(), vm.getStatus(), + vm.getVcpu(), vm.getMemoryMb(), vm.getDiskGb(), refs.workspaceId(), workspaceName, orgName, + displayName, refs.requestId(), vm.getStatusDetail(), vm.isSshGatewayBlocked(), + vm.getCreatedAt(), refs.orgId(), + refs.imageId(), ipAddress, vm.getSshUsername(), sshHost, myResourceRole, vm.getStartDate(), vm.getEndDate(), vm.getExpiryStoppedAt(), provisioning, - VmDeletionResponse.from(vm), publications, + VmDeletionResponse.from(vm, refs.deleteRequestedById()), publications, vm.getPasswordEnc() != null, passwordRevealAllowed, atLeastMember, atLeastMember, atLeastEditor, accessManageAllowed, accessManageAllowed, vm.getUpdatedAt()); diff --git a/src/main/java/kr/ac/pusan/pickle/vm/dto/VmEventResponse.java b/src/main/java/kr/ac/pusan/pickle/vm/dto/VmEventResponse.java index 462eeb38..9ec7c1df 100644 --- a/src/main/java/kr/ac/pusan/pickle/vm/dto/VmEventResponse.java +++ b/src/main/java/kr/ac/pusan/pickle/vm/dto/VmEventResponse.java @@ -1,20 +1,21 @@ package kr.ac.pusan.pickle.vm.dto; import java.time.Instant; +import java.util.UUID; import kr.ac.pusan.pickle.vm.VmEvent; import kr.ac.pusan.pickle.vm.VmEventType; import org.jspecify.annotations.Nullable; /** Contract schema {@code VmEvent} ({@code actorId} null = system/automatic). */ public record VmEventResponse( - Long id, + UUID id, VmEventType type, - @Nullable Long actorId, + @Nullable UUID actorId, @Nullable String detail, Instant createdAt) { - public static VmEventResponse from(VmEvent event) { - return new VmEventResponse(event.getId(), event.getType(), event.getActorId(), + public static VmEventResponse from(VmEvent event, UUID actorId) { + return new VmEventResponse(event.getPublicId(), event.getType(), actorId, event.getDetail(), event.getCreatedAt()); } } diff --git a/src/main/java/kr/ac/pusan/pickle/vm/dto/VmReferences.java b/src/main/java/kr/ac/pusan/pickle/vm/dto/VmReferences.java new file mode 100644 index 00000000..402bd31d --- /dev/null +++ b/src/main/java/kr/ac/pusan/pickle/vm/dto/VmReferences.java @@ -0,0 +1,25 @@ +package kr.ac.pusan.pickle.vm.dto; + +import java.util.UUID; +import org.jspecify.annotations.Nullable; + +/** + * The public identifiers of the rows a VM points at, resolved by the caller. + * + *

A VM carries its references as internal foreign keys, and the public face + * of each is a column on the referenced row — so the join has to happen where + * the query is, not in the view. This carries the result across rather than + * five more positional {@code UUID} parameters, which are trivially swapped. + * + *

Every field is nullable because these are history-preserving joins: a + * destroyed VM keeps its row after the workspace, org or account it named is + * gone, and a reference that no longer resolves is reported as absent rather + * than as a broken id. + */ +public record VmReferences( + @Nullable UUID workspaceId, + @Nullable UUID orgId, + @Nullable UUID imageId, + @Nullable UUID requestId, + @Nullable UUID deleteRequestedById) { +} 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 21821db6..9b33dea2 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 @@ -4,6 +4,7 @@ import java.time.Instant; import java.time.LocalDate; import java.util.List; +import java.util.UUID; import kr.ac.pusan.pickle.vm.Vm; import kr.ac.pusan.pickle.vm.VmStatus; import org.jspecify.annotations.Nullable; @@ -22,7 +23,7 @@ * display name there instead — see {@link #restricted}. */ public record VmSummaryResponse( - Long id, + UUID id, @Schema(description = "SSH 슬러그. 접근 권한이 없으면 대신 표시 이름이 들어갑니다.") String name, @Schema(description = "SSH 슬러그. 접근 권한이 없으면 생략됩니다.") @@ -34,11 +35,12 @@ public record VmSummaryResponse( @Nullable Integer memoryMb, @Schema(description = "디스크(GiB). 접근 권한이 없으면 생략됩니다.") @Nullable Integer diskGb, - Long workspaceId, + @Schema(description = "소유 워크스페이스. 행이 사라진 경우에만 null입니다.") + @Nullable UUID workspaceId, String workspaceName, @Nullable String orgName, @Nullable String displayName, - @Nullable Long requestId, + @Nullable UUID requestId, @Nullable String statusDetail, @Nullable Boolean sshGatewayBlocked, @Nullable LocalDate endDate, @@ -51,11 +53,11 @@ public record VmSummaryResponse( @Schema(description = "접근 권한이 없어도 접근 권한 목록을 관리할 수 있는지. 워크스페이스 소유자가 참입니다.") boolean accessManageAllowed) { - public static VmSummaryResponse from(Vm vm, String workspaceName, String orgName, - String displayName) { - return new VmSummaryResponse(vm.getId(), vm.getName(), vm.getHostname(), vm.getStatus(), - vm.getVcpu(), vm.getMemoryMb(), vm.getDiskGb(), vm.getWorkspaceId(), workspaceName, orgName, - displayName, vm.getRequestId(), vm.getStatusDetail(), vm.isSshGatewayBlocked(), + public static VmSummaryResponse from(Vm vm, UUID workspaceId, String workspaceName, + String orgName, String displayName, UUID requestId) { + return new VmSummaryResponse(vm.getPublicId(), vm.getName(), vm.getHostname(), vm.getStatus(), + vm.getVcpu(), vm.getMemoryMb(), vm.getDiskGb(), workspaceId, workspaceName, orgName, + displayName, requestId, vm.getStatusDetail(), vm.isSshGatewayBlocked(), vm.getEndDate(), vm.getExpiryStoppedAt(), vm.getCreatedAt(), false, List.of(), false); } @@ -69,10 +71,10 @@ public static VmSummaryResponse from(Vm vm, String workspaceName, String orgName * 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(), restrictedName(vm, displayName), null, vm.getStatus(), - null, null, null, vm.getWorkspaceId(), workspaceName, null, + public static VmSummaryResponse restricted(Vm vm, UUID workspaceId, String workspaceName, + String displayName, List ownerNames, boolean accessManageAllowed) { + return new VmSummaryResponse(vm.getPublicId(), restrictedName(vm, displayName), null, vm.getStatus(), + null, null, null, workspaceId, workspaceName, null, null, null, null, null, null, null, vm.getCreatedAt(), true, ownerNames, accessManageAllowed); } @@ -84,6 +86,6 @@ public static VmSummaryResponse restricted(Vm vm, String workspaceName, String d * {@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(); + return displayName != null && !displayName.isBlank() ? displayName : "VM #" + vm.getPublicId(); } } diff --git a/src/main/java/kr/ac/pusan/pickle/vmsettings/VmSettingsService.java b/src/main/java/kr/ac/pusan/pickle/vmsettings/VmSettingsService.java index 30d33a07..4052b2a4 100644 --- a/src/main/java/kr/ac/pusan/pickle/vmsettings/VmSettingsService.java +++ b/src/main/java/kr/ac/pusan/pickle/vmsettings/VmSettingsService.java @@ -7,6 +7,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.UUID; import java.util.function.Function; import java.util.stream.Collectors; import kr.ac.pusan.pickle.access.ResourceRole; @@ -206,8 +207,8 @@ public Map displayNames(java.util.Collection vmIds) { /** EDITOR+ only; non-member answers 404 (existence masking). */ @Transactional(readOnly = true) - public List get(AuthenticatedUser actor, long vmId) { - Vm vm = vmRepository.findById(vmId).orElseThrow(VmAccessService::vmNotFound); + public List get(AuthenticatedUser actor, UUID vmId) { + Vm vm = vmRepository.findByPublicId(vmId).orElseThrow(VmAccessService::vmNotFound); ResourceRole actorRole = memberRole(vm, actor); if (!actorRole.atLeast(ResourceRole.EDITOR)) { throw new ApiException(HttpStatus.FORBIDDEN, ErrorCodes.WORKSPACE_ROLE_INSUFFICIENT, @@ -222,9 +223,10 @@ public List get(AuthenticatedUser actor, long vmId) { * VM → 409. Every change is audited (old→new). Returns the full list. */ @Transactional - public List patch(AuthenticatedUser actor, long vmId, + public List patch(AuthenticatedUser actor, UUID publicVmId, Map settings, String ip) { - Vm vm = vmRepository.findById(vmId).orElseThrow(VmAccessService::vmNotFound); + Vm vm = vmRepository.findByPublicId(publicVmId).orElseThrow(VmAccessService::vmNotFound); + long vmId = vm.getId(); ResourceRole actorRole = memberRole(vm, actor); if (settings == null || settings.isEmpty()) { throw ApiException.validationFailed(List.of( @@ -278,7 +280,7 @@ public List patch(AuthenticatedUser actor, long vmId, settingRepository.save(row); } auditService.recordAfterCommit(actor.id(), actor.role().name(), def.auditAction(), - "vm", vmId, Map.of("key", def.key(), "old", oldValue, "new", newValue), ip); + "vm", vm.getPublicId(), Map.of("key", def.key(), "old", oldValue, "new", newValue), ip); } return buildViews(vm, actorRole); } diff --git a/src/main/java/kr/ac/pusan/pickle/workspace/Workspace.java b/src/main/java/kr/ac/pusan/pickle/workspace/Workspace.java index 0acc550c..a0a76606 100644 --- a/src/main/java/kr/ac/pusan/pickle/workspace/Workspace.java +++ b/src/main/java/kr/ac/pusan/pickle/workspace/Workspace.java @@ -9,6 +9,7 @@ import jakarta.persistence.Id; import jakarta.persistence.Table; import java.time.Instant; +import java.util.UUID; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.annotations.UpdateTimestamp; @@ -22,6 +23,14 @@ public class Workspace { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + /** + * The identifier this row wears outside the API boundary. Internal joins, + * sorts and foreign keys keep using {@link #id}. + */ + @JdbcTypeCode(SqlTypes.UUID) + @Column(name = "public_id", nullable = false, updatable = false, unique = true) + private UUID publicId = UUID.randomUUID(); + @Enumerated(EnumType.STRING) @JdbcTypeCode(SqlTypes.NAMED_ENUM) @Column(nullable = false, columnDefinition = "workspace_kind") @@ -61,6 +70,10 @@ public Long getId() { return id; } + public UUID getPublicId() { + return publicId; + } + public WorkspaceKind getKind() { return kind; } diff --git a/src/main/java/kr/ac/pusan/pickle/workspace/WorkspaceController.java b/src/main/java/kr/ac/pusan/pickle/workspace/WorkspaceController.java index 8719da7f..db73f40d 100644 --- a/src/main/java/kr/ac/pusan/pickle/workspace/WorkspaceController.java +++ b/src/main/java/kr/ac/pusan/pickle/workspace/WorkspaceController.java @@ -5,6 +5,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import java.util.List; +import java.util.UUID; import kr.ac.pusan.pickle.workspace.dto.AddWorkspaceMemberRequest; import kr.ac.pusan.pickle.workspace.dto.CreateWorkspaceRequest; import kr.ac.pusan.pickle.workspace.dto.WorkspaceDetailResponse; @@ -53,13 +54,13 @@ public WorkspaceDetailResponse createWorkspace( @GetMapping("/{workspaceId}") public WorkspaceDetailResponse getWorkspace(@AuthenticationPrincipal AuthenticatedUser principal, - @PathVariable long workspaceId) { + @PathVariable UUID workspaceId) { return workspaceService.get(principal, workspaceId); } @PatchMapping("/{workspaceId}") public WorkspaceDetailResponse updateWorkspace(@AuthenticationPrincipal AuthenticatedUser principal, - @PathVariable long workspaceId, + @PathVariable UUID workspaceId, @Valid @RequestBody UpdateWorkspaceRequest request) { return workspaceService.update(principal, workspaceId, request); } @@ -67,7 +68,7 @@ public WorkspaceDetailResponse updateWorkspace(@AuthenticationPrincipal Authenti @DeleteMapping("/{workspaceId}") @ResponseStatus(HttpStatus.NO_CONTENT) public void deleteWorkspace(@AuthenticationPrincipal AuthenticatedUser principal, - @PathVariable long workspaceId, + @PathVariable UUID workspaceId, HttpServletRequest httpRequest) { workspaceService.delete(principal, workspaceId, clientIp(httpRequest)); } @@ -77,7 +78,7 @@ public void deleteWorkspace(@AuthenticationPrincipal AuthenticatedUser principal @RequireReauth public WorkspaceMemberResponse addWorkspaceMember( @AuthenticationPrincipal AuthenticatedUser principal, - @PathVariable long workspaceId, + @PathVariable UUID workspaceId, @Valid @RequestBody AddWorkspaceMemberRequest request, HttpServletRequest httpRequest) { return workspaceService.addMember(principal, workspaceId, request, clientIp(httpRequest)); @@ -86,8 +87,8 @@ public WorkspaceMemberResponse addWorkspaceMember( @PatchMapping("/{workspaceId}/members/{userId}") @RequireReauth public WorkspaceMemberResponse updateWorkspaceMember(@AuthenticationPrincipal AuthenticatedUser principal, - @PathVariable long workspaceId, - @PathVariable long userId, + @PathVariable UUID workspaceId, + @PathVariable UUID userId, @Valid @RequestBody UpdateWorkspaceMemberRequest request, HttpServletRequest httpRequest) { return workspaceService.updateMemberRole(principal, workspaceId, userId, request, clientIp(httpRequest)); @@ -97,8 +98,8 @@ public WorkspaceMemberResponse updateWorkspaceMember(@AuthenticationPrincipal Au @ResponseStatus(HttpStatus.NO_CONTENT) @RequireReauth public void removeWorkspaceMember(@AuthenticationPrincipal AuthenticatedUser principal, - @PathVariable long workspaceId, - @PathVariable long userId, + @PathVariable UUID workspaceId, + @PathVariable UUID userId, HttpServletRequest httpRequest) { workspaceService.removeMember(principal, workspaceId, userId, clientIp(httpRequest)); } diff --git a/src/main/java/kr/ac/pusan/pickle/workspace/WorkspaceRepository.java b/src/main/java/kr/ac/pusan/pickle/workspace/WorkspaceRepository.java index 7ef22634..8864b648 100644 --- a/src/main/java/kr/ac/pusan/pickle/workspace/WorkspaceRepository.java +++ b/src/main/java/kr/ac/pusan/pickle/workspace/WorkspaceRepository.java @@ -1,14 +1,20 @@ package kr.ac.pusan.pickle.workspace; import java.util.Optional; +import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; public interface WorkspaceRepository extends JpaRepository { + /** Resolution of the identifier this row wears outside the API boundary. */ + Optional findByPublicId(UUID publicId); + /** A non-deleted workspace by id — deleted workspaces answer empty (masked 404). */ Optional findByIdAndDeletedAtIsNull(Long id); + Optional findByPublicIdAndDeletedAtIsNull(UUID publicId); + /** Workspace targeting / existence check that excludes soft-deleted workspaces. */ boolean existsByIdAndDeletedAtIsNull(Long id); } 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 1d82dc07..d9af756c 100644 --- a/src/main/java/kr/ac/pusan/pickle/workspace/WorkspaceService.java +++ b/src/main/java/kr/ac/pusan/pickle/workspace/WorkspaceService.java @@ -29,6 +29,7 @@ import kr.ac.pusan.pickle.request.RequestRepository; import kr.ac.pusan.pickle.request.RequestStatus; import java.time.Instant; +import java.util.UUID; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; @@ -92,22 +93,25 @@ public WorkspaceDetailResponse create(AuthenticatedUser actor, CreateWorkspaceRe request.name().strip(), normalize(request.description()))); workspaceMemberRepository.save(new WorkspaceMember(workspace, actor.id(), WorkspaceMemberRole.OWNER)); auditService.recordAfterCommit(actor.id(), actor.role().name(), AuditService.WORKSPACE_CREATE, - "workspace", workspace.getId(), + "workspace", workspace.getPublicId(), Map.of("kind", workspace.getKind().name(), "name", workspace.getName()), ip); return toDetail(workspace, WorkspaceMemberRole.OWNER); } @Transactional(readOnly = true) - public WorkspaceDetailResponse get(AuthenticatedUser actor, long workspaceId) { - Workspace workspace = findWorkspace(workspaceId); + public WorkspaceDetailResponse get(AuthenticatedUser actor, UUID publicWorkspaceId) { + Workspace workspace = findWorkspace(publicWorkspaceId); + long workspaceId = workspace.getId(); WorkspaceMember membership = workspaceMemberRepository.findByWorkspaceIdAndUserId(workspaceId, actor.id()) .orElseThrow(() -> accessDenied("워크스페이스 구성원만 조회할 수 있습니다.")); return toDetail(workspace, membership.getRole()); } @Transactional - public WorkspaceDetailResponse update(AuthenticatedUser actor, long workspaceId, UpdateWorkspaceRequest request) { - Workspace workspace = findWorkspace(workspaceId); + public WorkspaceDetailResponse update(AuthenticatedUser actor, UUID publicWorkspaceId, + UpdateWorkspaceRequest request) { + Workspace workspace = findWorkspace(publicWorkspaceId); + long workspaceId = workspace.getId(); WorkspaceMember membership = workspaceMemberRepository.findByWorkspaceIdAndUserId(workspaceId, actor.id()) .orElseThrow(() -> accessDenied("워크스페이스 소유자(OWNER)만 수정할 수 있습니다.")); if (membership.getRole() != WorkspaceMemberRole.OWNER) { @@ -131,9 +135,10 @@ public WorkspaceDetailResponse update(AuthenticatedUser actor, long workspaceId, } @Transactional - public WorkspaceMemberResponse addMember(AuthenticatedUser actor, long workspaceId, + public WorkspaceMemberResponse addMember(AuthenticatedUser actor, UUID publicWorkspaceId, AddWorkspaceMemberRequest request, String ip) { - Workspace workspace = findWorkspace(workspaceId); + Workspace workspace = findWorkspace(publicWorkspaceId); + long workspaceId = workspace.getId(); requireOwnerForMemberManagement(workspace, actor, "워크스페이스 소유자(OWNER)만 구성원을 추가할 수 있습니다.", "PERSONAL 워크스페이스에는 구성원을 추가할 수 없습니다."); if (request.role() == WorkspaceMemberRole.OWNER) { @@ -160,15 +165,17 @@ public WorkspaceMemberResponse addMember(AuthenticatedUser actor, long workspace "이미 워크스페이스 구성원입니다", "해당 사용자는 이미 이 워크스페이스의 구성원입니다."); } auditService.recordAfterCommit(actor.id(), actor.role().name(), AuditService.WORKSPACE_MEMBER_ADD, - "workspace", workspaceId, + "workspace", workspace.getPublicId(), Map.of("userId", target.getId(), "email", target.getEmail(), "role", member.getRole().name()), ip); return WorkspaceMemberResponse.from(member, target); } @Transactional - public WorkspaceMemberResponse updateMemberRole(AuthenticatedUser actor, long workspaceId, long targetUserId, - UpdateWorkspaceMemberRequest request, String ip) { - Workspace workspace = findWorkspace(workspaceId); + public WorkspaceMemberResponse updateMemberRole(AuthenticatedUser actor, UUID publicWorkspaceId, + UUID publicUserId, UpdateWorkspaceMemberRequest request, String ip) { + Workspace workspace = findWorkspace(publicWorkspaceId); + long workspaceId = workspace.getId(); + long targetUserId = memberUserId(publicUserId); requireOwnerForMemberManagement(workspace, actor, "워크스페이스 소유자(OWNER)만 역할을 변경할 수 있습니다.", "PERSONAL 워크스페이스의 구성원은 변경할 수 없습니다."); // Locked before the count below. With one owner per workspace the count could @@ -194,15 +201,18 @@ public WorkspaceMemberResponse updateMemberRole(AuthenticatedUser actor, long wo // never committed. User targetUser = userRepository.findById(targetUserId).orElseThrow(WorkspaceService::memberNotFound); auditService.recordAfterCommit(actor.id(), actor.role().name(), AuditService.WORKSPACE_MEMBER_UPDATE, - "workspace", workspaceId, + "workspace", workspace.getPublicId(), Map.of("userId", targetUserId, "previousRole", previousRole.name(), "role", target.getRole().name()), ip); return WorkspaceMemberResponse.from(target, targetUser); } @Transactional - public void removeMember(AuthenticatedUser actor, long workspaceId, long targetUserId, String ip) { - Workspace workspace = findWorkspace(workspaceId); + public void removeMember(AuthenticatedUser actor, UUID publicWorkspaceId, UUID publicUserId, + String ip) { + Workspace workspace = findWorkspace(publicWorkspaceId); + long workspaceId = workspace.getId(); + long targetUserId = memberUserId(publicUserId); if (workspace.getKind() == WorkspaceKind.PERSONAL) { throw memberManageForbidden("구성원을 관리할 권한이 없습니다", "PERSONAL 워크스페이스의 구성원은 변경할 수 없습니다."); } @@ -227,7 +237,7 @@ public void removeMember(AuthenticatedUser actor, long workspaceId, long targetU // rather than lying dormant until a rejoin silently restores them. int revokedGrants = revokeGrantsOnWorkspaceResources(workspaceId, targetUserId); auditService.recordAfterCommit(actor.id(), actor.role().name(), AuditService.WORKSPACE_MEMBER_REMOVE, - "workspace", workspaceId, + "workspace", workspace.getPublicId(), Map.of("userId", targetUserId, "previousRole", target.getRole().name(), "selfLeave", selfLeave, "revokedGrants", revokedGrants), ip); } @@ -260,9 +270,10 @@ private int revokeGrantsOnWorkspaceResources(long workspaceId, long userId) { * members are notified and the deletion is audited. */ @Transactional - public void delete(AuthenticatedUser actor, long workspaceId, String ip) { - Workspace workspace = workspaceRepository.findByIdAndDeletedAtIsNull(workspaceId) + public void delete(AuthenticatedUser actor, UUID publicWorkspaceId, String ip) { + Workspace workspace = workspaceRepository.findByPublicIdAndDeletedAtIsNull(publicWorkspaceId) .orElseThrow(WorkspaceService::workspaceNotFound); + long workspaceId = workspace.getId(); WorkspaceMember membership = workspaceMemberRepository.findByWorkspaceIdAndUserId(workspaceId, actor.id()) .orElseThrow(WorkspaceService::workspaceNotFound); // non-member: mask existence if (membership.getRole() != WorkspaceMemberRole.OWNER) { @@ -282,13 +293,14 @@ public void delete(AuthenticatedUser actor, long workspaceId, String ip) { // (409 REQUEST_ALREADY_DECIDED) once this delete commits. for (Request pending : requestRepository .findByWorkspaceIdAndStatus(workspaceId, RequestStatus.SUBMITTED)) { - Request locked = requestRepository.findWithLockById(pending.getId()).orElse(null); + Request locked = requestRepository.findWithLockByPublicId(pending.getPublicId()) + .orElse(null); if (locked == null || locked.getStatus() != RequestStatus.SUBMITTED) { continue; } locked.setStatus(RequestStatus.CANCELED); auditService.recordAfterCommit(actor.id(), actor.role().name(), - AuditService.REQUEST_CANCEL, "request", locked.getId(), + AuditService.REQUEST_CANCEL, "request", locked.getPublicId(), Map.of("workspaceId", workspaceId, "reason", "workspace_deleted"), ip); } @@ -310,9 +322,11 @@ public void delete(AuthenticatedUser actor, long workspaceId, String ip) { List recipients = notificationService.workspaceMemberIds(workspaceId); workspace.softDelete(actor.id(), Instant.now()); auditService.recordAfterCommit(actor.id(), actor.role().name(), AuditService.WORKSPACE_DELETE, - "workspace", workspaceId, Map.of("kind", workspace.getKind().name(), "name", workspace.getName()), ip); + "workspace", workspace.getPublicId(), + Map.of("kind", workspace.getKind().name(), "name", workspace.getName()), ip); notificationService.publish(recipients, NotificationEvent.WORKSPACE_DELETED, - Map.of("workspaceId", workspaceId, "workspaceName", workspace.getName()), "workspace_deleted:" + workspaceId); + Map.of("workspaceId", workspace.getPublicId(), "workspaceName", workspace.getName()), + "workspace_deleted:" + workspaceId); } /** @@ -341,11 +355,21 @@ private WorkspaceDetailResponse toDetail(Workspace workspace, WorkspaceMemberRol } /** All read/manage paths exclude soft-deleted workspaces — a deleted workspace answers 404. */ - private Workspace findWorkspace(long workspaceId) { - return workspaceRepository.findByIdAndDeletedAtIsNull(workspaceId) + private Workspace findWorkspace(UUID publicWorkspaceId) { + return workspaceRepository.findByPublicIdAndDeletedAtIsNull(publicWorkspaceId) .orElseThrow(WorkspaceService::workspaceNotFound); } + /** + * The member behind a public account id. An id no account has reads as + * "not a member of this workspace", the same 404 a real account outside + * the workspace gets. + */ + private long memberUserId(UUID publicUserId) { + return userRepository.findByPublicId(publicUserId).map(User::getId) + .orElseThrow(WorkspaceService::memberNotFound); + } + private static ApiException workspaceNotFound() { return new ApiException(HttpStatus.NOT_FOUND, ErrorCodes.RESOURCE_NOT_FOUND, "리소스를 찾을 수 없습니다", "해당 워크스페이스가 존재하지 않습니다."); diff --git a/src/main/java/kr/ac/pusan/pickle/workspace/dto/WorkspaceDetailResponse.java b/src/main/java/kr/ac/pusan/pickle/workspace/dto/WorkspaceDetailResponse.java index 7383440e..6d423c73 100644 --- a/src/main/java/kr/ac/pusan/pickle/workspace/dto/WorkspaceDetailResponse.java +++ b/src/main/java/kr/ac/pusan/pickle/workspace/dto/WorkspaceDetailResponse.java @@ -2,6 +2,7 @@ import java.time.Instant; import java.util.List; +import java.util.UUID; import kr.ac.pusan.pickle.workspace.Workspace; import kr.ac.pusan.pickle.workspace.WorkspaceKind; import kr.ac.pusan.pickle.workspace.WorkspaceMemberRole; @@ -9,7 +10,7 @@ /** Contract schema {@code WorkspaceDetail} (workspace with members + my role in it). */ public record WorkspaceDetailResponse( - Long id, + UUID id, WorkspaceKind kind, String name, @Nullable String description, @@ -19,7 +20,7 @@ public record WorkspaceDetailResponse( public static WorkspaceDetailResponse from(Workspace workspace, WorkspaceMemberRole myRole, List members) { - return new WorkspaceDetailResponse(workspace.getId(), workspace.getKind(), workspace.getName(), + return new WorkspaceDetailResponse(workspace.getPublicId(), workspace.getKind(), workspace.getName(), workspace.getDescription(), myRole, members, workspace.getCreatedAt()); } } diff --git a/src/main/java/kr/ac/pusan/pickle/workspace/dto/WorkspaceMemberResponse.java b/src/main/java/kr/ac/pusan/pickle/workspace/dto/WorkspaceMemberResponse.java index f6322a0a..84a9cc48 100644 --- a/src/main/java/kr/ac/pusan/pickle/workspace/dto/WorkspaceMemberResponse.java +++ b/src/main/java/kr/ac/pusan/pickle/workspace/dto/WorkspaceMemberResponse.java @@ -1,13 +1,15 @@ package kr.ac.pusan.pickle.workspace.dto; +import java.util.UUID; import kr.ac.pusan.pickle.workspace.WorkspaceMember; import kr.ac.pusan.pickle.workspace.WorkspaceMemberRole; import kr.ac.pusan.pickle.user.User; /** Contract schema {@code WorkspaceMember}. */ -public record WorkspaceMemberResponse(Long userId, String name, String email, WorkspaceMemberRole role) { +public record WorkspaceMemberResponse(UUID userId, String name, String email, WorkspaceMemberRole role) { public static WorkspaceMemberResponse from(WorkspaceMember member, User user) { - return new WorkspaceMemberResponse(member.getUserId(), user.getName(), user.getEmail(), member.getRole()); + return new WorkspaceMemberResponse(user.getPublicId(), user.getName(), user.getEmail(), + member.getRole()); } } diff --git a/src/main/java/kr/ac/pusan/pickle/workspace/dto/WorkspaceSummaryResponse.java b/src/main/java/kr/ac/pusan/pickle/workspace/dto/WorkspaceSummaryResponse.java index 1d13c1cb..db9fc243 100644 --- a/src/main/java/kr/ac/pusan/pickle/workspace/dto/WorkspaceSummaryResponse.java +++ b/src/main/java/kr/ac/pusan/pickle/workspace/dto/WorkspaceSummaryResponse.java @@ -1,13 +1,14 @@ package kr.ac.pusan.pickle.workspace.dto; import kr.ac.pusan.pickle.workspace.WorkspaceKind; +import java.util.UUID; import kr.ac.pusan.pickle.workspace.WorkspaceMember; import kr.ac.pusan.pickle.workspace.WorkspaceMemberRole; import org.jspecify.annotations.Nullable; /** Contract schema {@code WorkspaceSummary} (my-workspaces list). */ public record WorkspaceSummaryResponse( - Long id, + UUID id, WorkspaceKind kind, String name, @Nullable String description, @@ -16,7 +17,7 @@ public record WorkspaceSummaryResponse( public static WorkspaceSummaryResponse from(WorkspaceMember membership, long memberCount) { var workspace = membership.getWorkspace(); - return new WorkspaceSummaryResponse(workspace.getId(), workspace.getKind(), workspace.getName(), + return new WorkspaceSummaryResponse(workspace.getPublicId(), workspace.getKind(), workspace.getName(), workspace.getDescription(), membership.getRole(), memberCount); } } diff --git a/src/main/resources/db/migration/V78__public_ids.sql b/src/main/resources/db/migration/V78__public_ids.sql new file mode 100644 index 00000000..57d5a64b --- /dev/null +++ b/src/main/resources/db/migration/V78__public_ids.sql @@ -0,0 +1,102 @@ +-- Public identifiers: every id that crosses the API boundary becomes a UUID. +-- +-- Sequential ids in URLs disclose the platform's size and its growth rate. +-- Measured before this migration: vms 63 rows / max id 63, requests 72/72, +-- domains 75/75, routes 75/75 -- the number in the URL *is* the count. Others +-- leak cumulative creation including deletions (workspaces 236/286, users +-- 149/207, notifications 1,695/1,769). +-- +-- The primary keys stay bigint. Only the *public* face changes. Replacing the +-- keys would take 62 foreign keys, 27 id-ordered sorts, the relay's nftables +-- object names and the capacity-trend SQL with it, and a random primary key +-- costs index locality on every insert; a secondary unique index on tables of +-- this size costs nothing. So each exposed table gains one column. +-- +-- Which tables: derived from the generated contract, not from intuition -- a +-- table is here iff some id referring to it appears in a path, a query +-- parameter, a request body or a response field. That test excludes several +-- tables an earlier survey had listed: workspace_members, request_reviews, +-- user_status_changes, vm_settings, vm_request_details and terms_versions all +-- expose only *foreign* keys or a natural key, never their own row id. +-- +-- The column carries a database default even though the application generates +-- the value in Java. Six of these tables are also written by raw SQL that never +-- touches JPA -- the development seeder and the operator's inventory script -- +-- and a NOT NULL column without a default would break both. The default is a +-- safety net, not the intended path. + +alter table users add column public_id uuid not null default gen_random_uuid(); +alter table orgs add column public_id uuid not null default gen_random_uuid(); +alter table workspaces add column public_id uuid not null default gen_random_uuid(); +alter table vms add column public_id uuid not null default gen_random_uuid(); +alter table requests add column public_id uuid not null default gen_random_uuid(); +alter table campus_ip_requests add column public_id uuid not null default gen_random_uuid(); +alter table domains add column public_id uuid not null default gen_random_uuid(); +alter table routes add column public_id uuid not null default gen_random_uuid(); +alter table certificates add column public_id uuid not null default gen_random_uuid(); +alter table nodes add column public_id uuid not null default gen_random_uuid(); +alter table os_images add column public_id uuid not null default gen_random_uuid(); +alter table vm_flavors add column public_id uuid not null default gen_random_uuid(); +alter table notifications add column public_id uuid not null default gen_random_uuid(); +alter table announcements add column public_id uuid not null default gen_random_uuid(); +alter table user_ssh_keys add column public_id uuid not null default gen_random_uuid(); +alter table resource_access_grants add column public_id uuid not null default gen_random_uuid(); +alter table port_mappings add column public_id uuid not null default gen_random_uuid(); +alter table relays add column public_id uuid not null default gen_random_uuid(); +alter table ip_pools add column public_id uuid not null default gen_random_uuid(); +alter table ip_allocations add column public_id uuid not null default gen_random_uuid(); +alter table provisioning_tasks add column public_id uuid not null default gen_random_uuid(); +alter table drift_findings add column public_id uuid not null default gen_random_uuid(); +alter table vm_events add column public_id uuid not null default gen_random_uuid(); + +create unique index users_public_id_uidx on users (public_id); +create unique index orgs_public_id_uidx on orgs (public_id); +create unique index workspaces_public_id_uidx on workspaces (public_id); +create unique index vms_public_id_uidx on vms (public_id); +create unique index requests_public_id_uidx on requests (public_id); +create unique index campus_ip_requests_public_id_uidx on campus_ip_requests (public_id); +create unique index domains_public_id_uidx on domains (public_id); +create unique index routes_public_id_uidx on routes (public_id); +create unique index certificates_public_id_uidx on certificates (public_id); +create unique index nodes_public_id_uidx on nodes (public_id); +create unique index os_images_public_id_uidx on os_images (public_id); +create unique index vm_flavors_public_id_uidx on vm_flavors (public_id); +create unique index notifications_public_id_uidx on notifications (public_id); +create unique index announcements_public_id_uidx on announcements (public_id); +create unique index user_ssh_keys_public_id_uidx on user_ssh_keys (public_id); +create unique index resource_access_grants_public_id_uidx on resource_access_grants (public_id); +create unique index port_mappings_public_id_uidx on port_mappings (public_id); +create unique index relays_public_id_uidx on relays (public_id); +create unique index ip_pools_public_id_uidx on ip_pools (public_id); +create unique index ip_allocations_public_id_uidx on ip_allocations (public_id); +create unique index provisioning_tasks_public_id_uidx on provisioning_tasks (public_id); +create unique index drift_findings_public_id_uidx on drift_findings (public_id); +create unique index vm_events_public_id_uidx on vm_events (public_id); + +-- The audit trail records what a public id pointed at. +-- +-- target_id is polymorphic over 15 target_type values and has no foreign key, +-- so it cannot be migrated by joining: two of those values (group, vm_request) +-- name tables that no longer exist. Existing rows therefore keep their numbers +-- as text and mean what they always meant; new rows record the public id. +-- +-- The alternative was to leave the column numeric. That would have kept the +-- trail internally consistent and made the admin filter useless in the same +-- stroke: once every id on screen is a UUID, nobody can see the number to type +-- into it. The filter itself needs no change either way -- the query already +-- compares target_id::text and the controller already takes a String. +alter table audit_logs alter column target_id type text using target_id::text; + +comment on column audit_logs.target_id is + '감사 대상의 공개 식별자(UUID 문자열). V78 이전 행은 당시의 내부 숫자를 문자열로 담고 있다 — target_type이 15종이고 외래 키가 없어 조인으로 옮길 수 없으며, 그중 둘은 이미 없는 테이블을 가리킨다.'; + +-- The organisation slug goes with them: it existed to give an organisation a +-- stable name in a URL, which the public id now does. Nobody typed it. +alter table orgs drop column slug; + +-- Corrections owed by V76, collected in the migration convention and paid here +-- because this file touches nodes. The applied file cannot be edited: Flyway +-- checksums it, and editing one after deployment took the api down for twelve +-- minutes on 2026-08-10. +comment on column nodes.disk_capacity_gb is + '게스트 디스크가 놓이는 thin pool의 물리 용량(GiB). 운영자가 호스트에서 측정해 채우며, 측정 전에는 null이다. 오버프로비저닝을 전제하므로 배치 제한이 아니라 조언용 분모로만 쓴다. V76의 코멘트는 단위를 GB라 적었으나 저장값은 처음부터 GiB였고(측정 바이트를 1073741824로 나눈다), 값을 채우는 주체도 사설 레포의 스크립트 이름이 아니라 운영자로 적는 것이 옳다.'; diff --git a/src/test/java/kr/ac/pusan/pickle/ProvisioningEndToEndTest.java b/src/test/java/kr/ac/pusan/pickle/ProvisioningEndToEndTest.java index f3c38e25..9ab20508 100644 --- a/src/test/java/kr/ac/pusan/pickle/ProvisioningEndToEndTest.java +++ b/src/test/java/kr/ac/pusan/pickle/ProvisioningEndToEndTest.java @@ -12,6 +12,7 @@ import com.github.tomakehurst.wiremock.client.WireMock; import java.time.Duration; import java.util.Map; +import java.util.UUID; import java.util.regex.Matcher; import java.util.regex.Pattern; import kr.ac.pusan.pickle.mail.AsyncMailDispatcher; @@ -148,61 +149,65 @@ void flowFromSignupToProvisionedVm() throws Exception { Map.of("kind", "TEAM", "name", "종단 테스트 팀")) .andExpect(status().isCreated()) .andReturn(); - long workspaceId = objectMapper.readTree(workspaceResult.getResponse().getContentAsString()) - .get("id").asLong(); + long workspaceId = SeedFixtures.internalId(jdbcTemplate, "workspaces", + UUID.fromString(objectMapper + .readTree(workspaceResult.getResponse().getContentAsString()) + .get("id").asString())); // 4. reference data: the two request axes from the API; the seed org via // JDBC because it is hidden and USER tokens do not see it in /orgs long orgId = SeedFixtures.seedOrgId(jdbcTemplate); JsonNode images = getJson("/api/v1/os-images", userToken); JsonNode image = findBy(images, "name", "ubuntu-24.04"); - long imageId = image.get("id").asLong(); + long imageId = SeedFixtures.internalId(jdbcTemplate, "os_images", UUID.fromString(image.get("id").asString())); JsonNode flavors = getJson("/api/v1/vm-flavors", userToken); JsonNode flavor = findBy(flavors, "name", "basic"); - long flavorId = flavor.get("id").asLong(); + long flavorId = SeedFixtures.internalId(jdbcTemplate, "vm_flavors", UUID.fromString(flavor.get("id").asString())); // 5. submit the vm-request pre-filled with the chosen preset's specs MvcResult requestResult = postJson("/api/v1/requests", userToken, Map.of( "type", "VM", - "workspaceId", workspaceId, - "orgId", orgId, + "workspaceId", pub("workspaces", workspaceId), + "orgId", pub("orgs", orgId), "purpose", "종단 검증용 서버", "vm", Map.of( - "imageId", imageId, - "flavorId", flavorId, + "imageId", pub("os_images", imageId), + "flavorId", pub("vm_flavors", flavorId), "reqVcpu", flavor.get("vcpu").asInt(), "reqMemoryMb", flavor.get("memoryMb").asInt(), "reqDiskGb", flavor.get("diskGb").asInt()))) .andExpect(status().isCreated()) .andExpect(jsonPath("$.status").value("SUBMITTED")) .andReturn(); - long requestId = objectMapper.readTree(requestResult.getResponse().getContentAsString()) - .get("id").asLong(); + long requestId = SeedFixtures.internalId(jdbcTemplate, "requests", + UUID.fromString(objectMapper + .readTree(requestResult.getResponse().getContentAsString()) + .get("id").asString())); // 6. the seeded ORG_ADMIN logs in and finds the request in the queue String adminToken = login(SeedFixtures.ORGADMIN_EMAIL, "pickle-test-orgadmin!"); mockMvc.perform(get("/api/v1/admin/requests?status=SUBMITTED") .header("Authorization", "Bearer " + adminToken)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.content[?(@.id == %d)]".formatted(requestId)).exists()); + .andExpect(jsonPath("$.content[?(@.id == '%s')]".formatted(pub("requests", requestId))).exists()); // 7. the approval context loads with all panels - mockMvc.perform(get("/api/v1/admin/requests/" + requestId + "/context") + mockMvc.perform(get("/api/v1/admin/requests/" + pub("requests", requestId) + "/context") .header("Authorization", "Bearer " + adminToken)) .andExpect(status().isOk()) .andExpect(jsonPath("$.applicant.email").value(USER_EMAIL)) - .andExpect(jsonPath("$.workspace.id").value(workspaceId)) + .andExpect(jsonPath("$.workspace.id").value(pub("workspaces", workspaceId).toString())) .andExpect(jsonPath("$.orgHeadroom.capacity.cpuThreads").value(40)) .andExpect(jsonPath("$.guidance").isNotEmpty()); // 8. approve with the requested spec - postJson("/api/v1/admin/requests/" + requestId + "/approve", adminToken, Map.of( + postJson("/api/v1/admin/requests/" + pub("requests", requestId) + "/approve", adminToken, Map.of( "comment", "요청 사양 그대로 승인합니다.", "vm", Map.of( "grantedVcpu", flavor.get("vcpu").asInt(), "grantedMemoryMb", flavor.get("memoryMb").asInt(), "grantedDiskGb", flavor.get("diskGb").asInt(), - "grantedImageId", imageId))) + "grantedImageId", pub("os_images", imageId)))) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value("APPROVED")) .andExpect(jsonPath("$.review.decision").value("APPROVE")); @@ -210,7 +215,7 @@ void flowFromSignupToProvisionedVm() throws Exception { // 9. the background job server runs the real pipeline to completion: // /vms must show RUNNING — the test fails if the VM stays CREATING await().atMost(Duration.ofSeconds(120)).pollInterval(Duration.ofSeconds(1)).untilAsserted(() -> - mockMvc.perform(get("/api/v1/vms?workspaceId=" + workspaceId) + mockMvc.perform(get("/api/v1/vms?workspaceId=" + pub("workspaces", workspaceId)) .header("Authorization", "Bearer " + userToken)) .andExpect(status().isOk()) .andExpect(jsonPath("$.totalElements").value(1)) @@ -219,17 +224,18 @@ void flowFromSignupToProvisionedVm() throws Exception { // 10. VM summary/detail carry the granted spec and the pipeline results JsonNode vms = getJson("/api/v1/vms", userToken); JsonNode vm = vms.get("content").get(0); - long vmId = vm.get("id").asLong(); + long vmId = SeedFixtures.internalId(jdbcTemplate, "vms", UUID.fromString(vm.get("id").asString())); // No display name was sent, so the seed identifies the workspace. assertThat(vm.get("hostname").asString()).matches("vm" + workspaceId + "-[a-z0-9]{4}"); - assertThat(vm.get("requestId").asLong()).isEqualTo(requestId); + assertThat(vm.get("requestId").asString()) + .isEqualTo(pub("requests", requestId).toString()); assertThat(vm.get("statusDetail").asString()).isEqualTo("프로비저닝 완료"); - mockMvc.perform(get("/api/v1/vms/" + vmId) + mockMvc.perform(get("/api/v1/vms/" + pub("vms", vmId)) .header("Authorization", "Bearer " + userToken)) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value("RUNNING")) .andExpect(jsonPath("$.sshUsername").value("ubuntu")) - .andExpect(jsonPath("$.orgId").value(orgId)) + .andExpect(jsonPath("$.orgId").value(pub("orgs", orgId).toString())) .andExpect(jsonPath("$.ipAddress").value(EXPECTED_IP)) .andExpect(jsonPath("$.passwordAvailable").value(true)) // contract v0.3.1: provisioning surfaces only in-flight or @@ -333,4 +339,9 @@ private ResultActions postJson(String uri, String token, Map body) th } return mockMvc.perform(request); } + + /** The public identifier of a row this test set up through direct SQL. */ + private UUID pub(String table, long id) { + return SeedFixtures.publicId(jdbcTemplate, table, id); + } } diff --git a/src/test/java/kr/ac/pusan/pickle/access/VmAccessGrantApiTest.java b/src/test/java/kr/ac/pusan/pickle/access/VmAccessGrantApiTest.java index e2ba7ca4..0ca34394 100644 --- a/src/test/java/kr/ac/pusan/pickle/access/VmAccessGrantApiTest.java +++ b/src/test/java/kr/ac/pusan/pickle/access/VmAccessGrantApiTest.java @@ -153,36 +153,36 @@ void onlyTheResourceOwnerAndTheWorkspaceOwnerManageTheList() throws Exception { long vmId = createVm(); // the VM's own owner reads the list and sees the entry that made them one - listGrants(vmOwnerToken, vmId) + listGrants(vmOwnerToken, pub("vms", vmId)) .andExpect(status().isOk()) .andExpect(jsonPath("$.grants.length()").value(1)) // The list names its VM so that whoever may manage it without // being able to open it still knows what they are deciding about. - .andExpect(jsonPath("$.resource.id").value((int) vmId)) - .andExpect(jsonPath("$.resource.workspaceId").value((int) workspaceId)) + .andExpect(jsonPath("$.resource.id").value(pub("vms", vmId).toString())) + .andExpect(jsonPath("$.resource.workspaceId").value(pub("workspaces", workspaceId).toString())) .andExpect(jsonPath("$.grants[0].granteeType").value("USER")) - .andExpect(jsonPath("$.grants[0].user.userId").value(vmOwner.getId().intValue())) + .andExpect(jsonPath("$.grants[0].user.userId").value(vmOwner.getPublicId().toString())) .andExpect(jsonPath("$.grants[0].user.name").value("자원소유자")) .andExpect(jsonPath("$.grants[0].role").value("OWNER")); grantVmToUser(jdbcTemplate, vmId, viewer.getId(), "VIEWER"); // the recovery path: a workspace owner with no grant at all manages it too - listGrants(workspaceOwnerToken, vmId) + listGrants(workspaceOwnerToken, pub("vms", vmId)) .andExpect(status().isOk()) .andExpect(jsonPath("$.grants.length()").value(2)); // a plain member of the owning workspace, and a VIEWER grant, are both refused // in the open — they can already see the VM listed, so hiding it would lie - listGrants(memberToken, vmId) + listGrants(memberToken, pub("vms", vmId)) .andExpect(status().isForbidden()) .andExpect(jsonPath("$.code").value("WORKSPACE_ROLE_INSUFFICIENT")); - listGrants(viewerToken, vmId) + listGrants(viewerToken, pub("vms", vmId)) .andExpect(status().isForbidden()) .andExpect(jsonPath("$.code").value("WORKSPACE_ROLE_INSUFFICIENT")); // an outsider is not told the VM exists - listGrants(outsiderToken, vmId) + listGrants(outsiderToken, pub("vms", vmId)) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND")); @@ -195,7 +195,7 @@ void onlyTheResourceOwnerAndTheWorkspaceOwnerManageTheList() throws Exception { .andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND")); // an id that names no VM is the same 404, for a manager as for anyone - listGrants(vmOwnerToken, 999_999) + listGrants(vmOwnerToken, SeedFixtures.UNKNOWN_ID) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND")); } @@ -239,7 +239,7 @@ void aUserGrantOnlyNamesAnActiveMemberOfTheOwningWorkspace() throws Exception { addGrant(vmOwnerToken, vmId, userGrant(member.getId(), "EDITOR")) .andExpect(status().isCreated()) .andExpect(jsonPath("$.granteeType").value("USER")) - .andExpect(jsonPath("$.user.userId").value(member.getId().intValue())) + .andExpect(jsonPath("$.user.userId").value(member.getPublicId().toString())) .andExpect(jsonPath("$.role").value("EDITOR")) .andExpect(jsonPath("$.createdAt").isNotEmpty()); assertThat(grantCount(vmId)).isEqualTo(2); @@ -264,23 +264,23 @@ void aWorkspaceWideGrantIsCappedAtMemberOnAddAndOnUpdate() throws Exception { // the two permitted rungs go in, and a workspace-wide row names nobody long grantId = addGrantId(vmOwnerToken, vmId, workspaceGrant("MEMBER")); - listGrants(vmOwnerToken, vmId) + listGrants(vmOwnerToken, pub("vms", vmId)) .andExpect(status().isOk()) .andExpect(jsonPath("$.grants[1].granteeType").value("WORKSPACE")) .andExpect(jsonPath("$.grants[1].user").value((Object) null)) .andExpect(jsonPath("$.grants[1].role").value("MEMBER")); // the cap holds on PATCH … - updateGrant(vmOwnerToken, vmId, grantId, "EDITOR") + updateGrant(vmOwnerToken, vmId, pub("resource_access_grants", grantId), "EDITOR") .andExpect(status().isUnprocessableEntity()) .andExpect(jsonPath("$.errors[0].field").value("role")); - updateGrant(vmOwnerToken, vmId, grantId, "OWNER") + updateGrant(vmOwnerToken, vmId, pub("resource_access_grants", grantId), "OWNER") .andExpect(status().isUnprocessableEntity()) .andExpect(jsonPath("$.errors[0].field").value("role")); // … and the refused PATCHes left the stored rung alone assertThat(roleOfGrant(grantId)).isEqualTo("MEMBER"); - updateGrant(vmOwnerToken, vmId, grantId, "VIEWER") + updateGrant(vmOwnerToken, vmId, pub("resource_access_grants", grantId), "VIEWER") .andExpect(status().isOk()) .andExpect(jsonPath("$.role").value("VIEWER")); assertThat(roleOfGrant(grantId)).isEqualTo("VIEWER"); @@ -332,24 +332,24 @@ void theEffectiveRungIsTheHigherOfTheOwnAndWorkspaceWideGrant() throws Exception grantVmToUser(jdbcTemplate, vmId, viewer.getId(), "VIEWER"); // VIEWER alone: the VM is visible, and nothing inside it is reachable - mockMvc.perform(get("/api/v1/vms/" + vmId) + mockMvc.perform(get("/api/v1/vms/" + pub("vms", vmId)) .header("Authorization", "Bearer " + viewerToken)) .andExpect(status().isOk()) .andExpect(jsonPath("$.myResourceRole").value("VIEWER")) .andExpect(jsonPath("$.powerControlAllowed").value(false)); - mockMvc.perform(post("/api/v1/vms/" + vmId + "/start") + mockMvc.perform(post("/api/v1/vms/" + pub("vms", vmId) + "/start") .header("Authorization", "Bearer " + viewerToken)) .andExpect(status().isForbidden()) .andExpect(jsonPath("$.code").value("WORKSPACE_ROLE_INSUFFICIENT")); // the workspace-wide grant raises everyone in the workspace, this person included grantVmToOwningWorkspace(jdbcTemplate, vmId, "MEMBER"); - mockMvc.perform(get("/api/v1/vms/" + vmId) + mockMvc.perform(get("/api/v1/vms/" + pub("vms", vmId)) .header("Authorization", "Bearer " + viewerToken)) .andExpect(status().isOk()) .andExpect(jsonPath("$.myResourceRole").value("MEMBER")) .andExpect(jsonPath("$.powerControlAllowed").value(true)); - mockMvc.perform(post("/api/v1/vms/" + vmId + "/start") + mockMvc.perform(post("/api/v1/vms/" + pub("vms", vmId) + "/start") .header("Authorization", "Bearer " + viewerToken)) .andExpect(status().isAccepted()); @@ -358,7 +358,7 @@ void theEffectiveRungIsTheHigherOfTheOwnAndWorkspaceWideGrant() throws Exception // and the workspace-wide grant reaches only the owning workspace — an outsider // is still not told the VM exists - mockMvc.perform(get("/api/v1/vms/" + vmId) + mockMvc.perform(get("/api/v1/vms/" + pub("vms", vmId)) .header("Authorization", "Bearer " + outsiderToken)) .andExpect(status().isNotFound()); } @@ -396,7 +396,7 @@ void aWorkspaceOwnerLettingThemselvesInIsRecordedAsBreakGlass() throws Exception assertThat(auditCount(AuditService.VM_ACCESS_BREAK_GLASS, workspaceWideVm)).isEqualTo(1); // taking access away opens no door, whoever it belongs to - deleteGrant(workspaceOwnerToken, vmId, selfGrantId) + deleteGrant(workspaceOwnerToken, vmId, pub("resource_access_grants", selfGrantId)) .andExpect(status().isNoContent()); assertThat(auditCount(AuditService.VM_ACCESS_GRANT_REMOVE, vmId)).isEqualTo(1); assertThat(auditCount(AuditService.VM_ACCESS_BREAK_GLASS, vmId)).isEqualTo(1); @@ -425,7 +425,7 @@ void aSelfGrantBelowMemberIsNotBreakGlass() throws Exception { assertThat(auditCount(AuditService.VM_ACCESS_BREAK_GLASS, vmId)).isZero(); // raising their own rung to one that reaches the contents is the event - updateGrant(workspaceOwnerToken, vmId, selfGrantId, "MEMBER") + updateGrant(workspaceOwnerToken, vmId, pub("resource_access_grants", selfGrantId), "MEMBER") .andExpect(status().isOk()); assertThat(auditCount(AuditService.VM_ACCESS_GRANT_UPDATE, vmId)).isEqualTo(1); assertThat(auditCount(AuditService.VM_ACCESS_BREAK_GLASS, vmId)).isEqualTo(1); @@ -456,7 +456,7 @@ void anActorAlreadyInsideNeverTripsBreakGlassNotEvenWhenSteppingDown() throws Ex // and stepping their own rung down is the opposite of breaking glass, // even though they are still above the line the predicate looks at - updateGrant(memberToken, vmId, ownGrantId, "MEMBER") + updateGrant(memberToken, vmId, pub("resource_access_grants", ownGrantId), "MEMBER") .andExpect(status().isOk()); assertThat(auditCount(AuditService.VM_ACCESS_GRANT_UPDATE, vmId)).isEqualTo(1); assertThat(auditCount(AuditService.VM_ACCESS_BREAK_GLASS, vmId)).isZero(); @@ -479,7 +479,7 @@ void leavingTheOwningWorkspaceTakesThatPersonsGrants() throws Exception { grantVmToUser(jdbcTemplate, firstVm, viewer.getId(), "VIEWER"); // removed by the workspace owner: both grants go, and the audit counts them - mockMvc.perform(delete("/api/v1/workspaces/" + workspaceId + "/members/" + member.getId()) + mockMvc.perform(delete("/api/v1/workspaces/" + pub("workspaces", workspaceId) + "/members/" + member.getPublicId()) .header("Authorization", "Bearer " + workspaceOwnerToken) .header(ReauthTestSupport.HEADER, reauth(workspaceOwnerToken))) .andExpect(status().isNoContent()); @@ -488,12 +488,12 @@ void leavingTheOwningWorkspaceTakesThatPersonsGrants() throws Exception { .isEqualTo("2"); // the VM they were an EDITOR of is now invisible to them, not merely closed - mockMvc.perform(get("/api/v1/vms/" + firstVm) + mockMvc.perform(get("/api/v1/vms/" + pub("vms", firstVm)) .header("Authorization", "Bearer " + memberToken)) .andExpect(status().isNotFound()); // a withdrawal of one's own accord is the same cleanup - mockMvc.perform(delete("/api/v1/workspaces/" + workspaceId + "/members/" + viewer.getId()) + mockMvc.perform(delete("/api/v1/workspaces/" + pub("workspaces", workspaceId) + "/members/" + viewer.getPublicId()) .header("Authorization", "Bearer " + viewerToken) .header(ReauthTestSupport.HEADER, reauth(viewerToken))) .andExpect(status().isNoContent()); @@ -517,7 +517,7 @@ void approvalRefusesARequesterWhoCanNoLongerHoldAGrant() throws Exception { // (a) the requester left the workspace between submitting and the decision User departing = workspaceMember("vmacc.departing", "탈퇴신청자"); long departedRequest = submitRequest(jwtService.createAccessToken(departing)); - mockMvc.perform(delete("/api/v1/workspaces/" + workspaceId + "/members/" + departing.getId()) + mockMvc.perform(delete("/api/v1/workspaces/" + pub("workspaces", workspaceId) + "/members/" + departing.getPublicId()) .header("Authorization", "Bearer " + workspaceOwnerToken) .header(ReauthTestSupport.HEADER, reauth(workspaceOwnerToken))) .andExpect(status().isNoContent()); @@ -538,7 +538,7 @@ void approvalRefusesARequesterWhoCanNoLongerHoldAGrant() throws Exception { // decidable so the reviewer can reject it instead assertThat(vmCountForRequest(departedRequest)).isZero(); assertThat(vmCountForRequest(suspendedRequest)).isZero(); - mockMvc.perform(get("/api/v1/admin/requests/" + departedRequest) + mockMvc.perform(get("/api/v1/admin/requests/" + pub("requests", departedRequest)) .header("Authorization", "Bearer " + orgAdminToken)) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value("SUBMITTED")); @@ -558,15 +558,15 @@ void aGrantIdFromAnotherVmOrNoVmAtAllIsNotFound() throws Exception { long foreignGrantId = addGrantId(vmOwnerToken, otherVmId, userGrant(member.getId(), "MEMBER")); - updateGrant(vmOwnerToken, vmId, foreignGrantId, "VIEWER") + updateGrant(vmOwnerToken, vmId, pub("resource_access_grants", foreignGrantId), "VIEWER") .andExpect(status().isNotFound()) .andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND")); - deleteGrant(vmOwnerToken, vmId, foreignGrantId) + deleteGrant(vmOwnerToken, vmId, pub("resource_access_grants", foreignGrantId)) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND")); - updateGrant(vmOwnerToken, vmId, 999_999_999L, "VIEWER") + updateGrant(vmOwnerToken, vmId, SeedFixtures.UNKNOWN_ID, "VIEWER") .andExpect(status().isNotFound()); - deleteGrant(vmOwnerToken, vmId, 999_999_999L) + deleteGrant(vmOwnerToken, vmId, SeedFixtures.UNKNOWN_ID) .andExpect(status().isNotFound()); // the neighbour's list survived every one of those attempts @@ -610,17 +610,21 @@ void theWriteOperationsNeedSudoMode() throws Exception { // nothing changed, and reading the list never needed the header assertThat(roleOfGrant(grantId)).isEqualTo("MEMBER"); assertThat(grantCount(vmId)).isEqualTo(2); - listGrants(vmOwnerToken, vmId).andExpect(status().isOk()); + listGrants(vmOwnerToken, pub("vms", vmId)).andExpect(status().isOk()); } // ── request helpers ──────────────────────────────────────────────────── - private static String accessPath(long vmId) { - return "/api/v1/vms/" + vmId + "/access"; + private String accessPath(long vmId) { + return accessPath(pub("vms", vmId)); } - private ResultActions listGrants(String token, long vmId) throws Exception { - return mockMvc.perform(get(accessPath(vmId)) + private String accessPath(UUID vmPublicId) { + return "/api/v1/vms/" + vmPublicId + "/access"; + } + + private ResultActions listGrants(String token, UUID vmPublicId) throws Exception { + return mockMvc.perform(get(accessPath(vmPublicId)) .header("Authorization", "Bearer " + token)); } @@ -638,28 +642,28 @@ private long addGrantId(String token, long vmId, Map body) throw String response = addGrant(token, vmId, body) .andExpect(status().isCreated()) .andReturn().getResponse().getContentAsString(); - return objectMapper.readTree(response).get("id").asLong(); + return SeedFixtures.internalId(jdbcTemplate, "resource_access_grants", UUID.fromString(objectMapper.readTree(response).get("id").asString())); } - private ResultActions updateGrant(String token, long vmId, long grantId, String role) + private ResultActions updateGrant(String token, long vmId, UUID grantPublicId, String role) throws Exception { - return mockMvc.perform(patch(accessPath(vmId) + "/" + grantId) + return mockMvc.perform(patch(accessPath(vmId) + "/" + grantPublicId) .header("Authorization", "Bearer " + token) .header(ReauthTestSupport.HEADER, reauth(token)) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(Map.of("role", role)))); } - private ResultActions deleteGrant(String token, long vmId, long grantId) throws Exception { - return mockMvc.perform(delete(accessPath(vmId) + "/" + grantId) + private ResultActions deleteGrant(String token, long vmId, UUID grantPublicId) throws Exception { + return mockMvc.perform(delete(accessPath(vmId) + "/" + grantPublicId) .header("Authorization", "Bearer " + token) .header(ReauthTestSupport.HEADER, reauth(token))); } - private static Map userGrant(long userId, String role) { + private Map userGrant(long userId, String role) { Map body = new HashMap<>(); body.put("granteeType", "USER"); - body.put("userId", userId); + body.put("userId", pub("users", userId)); body.put("role", role); return body; } @@ -703,15 +707,15 @@ private User workspaceMember(String emailPrefix, String name) throws Exception { private long submitRequest(String token) throws Exception { Map vm = new HashMap<>(); - vm.put("imageId", image.getId()); - vm.put("flavorId", flavor.getId()); + vm.put("imageId", image.getPublicId()); + vm.put("flavorId", flavor.getPublicId()); vm.put("reqVcpu", flavor.getVcpu()); vm.put("reqMemoryMb", flavor.getMemoryMb()); vm.put("reqDiskGb", flavor.getDiskGb()); Map body = new HashMap<>(); body.put("type", "VM"); - body.put("workspaceId", workspaceId); - body.put("orgId", orgId); + body.put("workspaceId", pub("workspaces", workspaceId)); + body.put("orgId", pub("orgs", orgId)); body.put("purpose", "접근 권한 테스트용 신청"); body.put("vm", vm); String response = mockMvc.perform(post("/api/v1/requests") @@ -720,7 +724,7 @@ private long submitRequest(String token) throws Exception { .content(objectMapper.writeValueAsString(body))) .andExpect(status().isCreated()) .andReturn().getResponse().getContentAsString(); - return objectMapper.readTree(response).get("id").asLong(); + return SeedFixtures.internalId(jdbcTemplate, "requests", UUID.fromString(objectMapper.readTree(response).get("id").asString())); } private ResultActions approveRequest(long requestId) throws Exception { @@ -728,9 +732,9 @@ private ResultActions approveRequest(long requestId) throws Exception { body.put("grantedVcpu", flavor.getVcpu()); body.put("grantedMemoryMb", flavor.getMemoryMb()); body.put("grantedDiskGb", flavor.getDiskGb()); - body.put("grantedImageId", image.getId()); + body.put("grantedImageId", image.getPublicId()); body.put("comment", "신청 사양 그대로 승인합니다."); - return mockMvc.perform(post("/api/v1/admin/requests/" + requestId + "/approve") + return mockMvc.perform(post("/api/v1/admin/requests/" + pub("requests", requestId) + "/approve") .header("Authorization", "Bearer " + orgAdminToken) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(body))); @@ -744,12 +748,12 @@ private long createTeam(String slug) throws Exception { "name", "접근 권한 테스트 " + slug, "slug", slug)))) .andExpect(status().isCreated()) .andReturn().getResponse().getContentAsString(); - return objectMapper.readTree(body).get("id").asLong(); + return SeedFixtures.internalId(jdbcTemplate, "workspaces", UUID.fromString(objectMapper.readTree(body).get("id").asString())); } /** Adds a plain member: the workspace ladder has only OWNER and MEMBER left. */ private void addMember(long workspaceId, String email) throws Exception { - mockMvc.perform(post("/api/v1/workspaces/" + workspaceId + "/members") + mockMvc.perform(post("/api/v1/workspaces/" + pub("workspaces", workspaceId) + "/members") .header("Authorization", "Bearer " + workspaceOwnerToken) .header(ReauthTestSupport.HEADER, reauth(workspaceOwnerToken)) .contentType(MediaType.APPLICATION_JSON) @@ -813,7 +817,7 @@ private int auditCount(String action, long vmId) { return jdbcTemplate.queryForObject(""" select count(*) from audit_logs where action = ? and target_type = 'vm' and target_id = ? - """, Integer.class, action, vmId); + """, Integer.class, action, pub("vms", vmId).toString()); } /** One key out of the newest audit detail for {@code action} on this VM. */ @@ -822,7 +826,7 @@ private String auditDetail(String action, long vmId, String key) { select detail ->> ? from audit_logs where action = ? and target_type = 'vm' and target_id = ? order by id desc limit 1 - """, String.class, key, action, vmId); + """, String.class, key, action, pub("vms", vmId).toString()); } private String workspaceAuditDetail(String action, long workspaceId, String key) { @@ -830,11 +834,16 @@ private String workspaceAuditDetail(String action, long workspaceId, String key) select detail ->> ? from audit_logs where action = ? and target_type = 'workspace' and target_id = ? order by id desc limit 1 - """, String.class, key, action, workspaceId); + """, String.class, key, action, pub("workspaces", workspaceId).toString()); } private int vmCountForRequest(long requestId) { return jdbcTemplate.queryForObject("select count(*) from vms where request_id = ?", Integer.class, requestId); } + + /** The public identifier of a row this test set up through direct SQL. */ + private UUID pub(String table, long id) { + return SeedFixtures.publicId(jdbcTemplate, table, id); + } } diff --git a/src/test/java/kr/ac/pusan/pickle/admin/AdminDriftFindingsTest.java b/src/test/java/kr/ac/pusan/pickle/admin/AdminDriftFindingsTest.java index b87edd2a..6e174bf5 100644 --- a/src/test/java/kr/ac/pusan/pickle/admin/AdminDriftFindingsTest.java +++ b/src/test/java/kr/ac/pusan/pickle/admin/AdminDriftFindingsTest.java @@ -237,37 +237,37 @@ void listDefaultsToOpenAndFiltersByKindAndIsSysAdminOnly() throws Exception { void resolveIsCasAnsweres409WhenAlreadyResolvedAnd404WhenUnknown() throws Exception { long findingId = insertFinding("UNMANAGED_GUEST", "vmid:70011", "OPEN"); - mockMvc.perform(post("/api/v1/admin/drift-findings/{id}/resolve", findingId) + mockMvc.perform(post("/api/v1/admin/drift-findings/{id}/resolve", pub("drift_findings", findingId)) .header("Authorization", "Bearer " + sysAdminToken) .contentType(MediaType.APPLICATION_JSON) .content("{\"note\": \"잔여 게스트를 수동 정리했습니다.\"}")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.id").value(findingId)) + .andExpect(jsonPath("$.id").value(pub("drift_findings", findingId).toString())) .andExpect(jsonPath("$.status").value("RESOLVED")) - .andExpect(jsonPath("$.resolvedById").isNumber()) + .andExpect(jsonPath("$.resolvedById").isNotEmpty()) .andExpect(jsonPath("$.resolvedByEmail").value(SeedFixtures.SYSADMIN_EMAIL)) .andExpect(jsonPath("$.resolutionNote").value("잔여 게스트를 수동 정리했습니다.")); // manual resolve is audited assertThat(jdbcTemplate.queryForObject( "select count(*) from audit_logs where action = 'drift.resolve' and target_id = ?", - Long.class, findingId)).isEqualTo(1); + Long.class, pub("drift_findings", findingId).toString())).isEqualTo(1); // second resolve → CAS finds 0 rows → 409 - mockMvc.perform(post("/api/v1/admin/drift-findings/{id}/resolve", findingId) + mockMvc.perform(post("/api/v1/admin/drift-findings/{id}/resolve", pub("drift_findings", findingId).toString()) .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isConflict()) .andExpect(jsonPath("$.code").value("DRIFT_FINDING_ALREADY_RESOLVED")); // unknown finding → 404 - mockMvc.perform(post("/api/v1/admin/drift-findings/999999/resolve") + mockMvc.perform(post("/api/v1/admin/drift-findings/" + SeedFixtures.UNKNOWN_ID + "/resolve") .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND")); // over-long note → 422 before any state change long another = insertFinding("UNMANAGED_GUEST", "vmid:70012", "OPEN"); - mockMvc.perform(post("/api/v1/admin/drift-findings/{id}/resolve", another) + mockMvc.perform(post("/api/v1/admin/drift-findings/{id}/resolve", pub("drift_findings", another)) .header("Authorization", "Bearer " + sysAdminToken) .contentType(MediaType.APPLICATION_JSON) .content("{\"note\": \"" + "가".repeat(2001) + "\"}")) @@ -298,8 +298,8 @@ private static String qemu(int vmid, String status, int maxcpu, long memoryMb, S .formatted(vmid, vmid, status, memoryMb * 1024 * 1024, maxcpu, tagsField); } - private static String byId(long findingId) { - return "$.content[?(@.id == %d)]".formatted(findingId); + private String byId(long findingId) { + return "$.content[?(@.id == '%s')]".formatted(pub("drift_findings", findingId)); } /** Enum/jsonb columns cast to text so plain JDBC types come back. */ @@ -364,4 +364,9 @@ insert into vms (node_id, workspace_id, org_id, request_id, name, hostname, """, Long.class, nodeId, workspaceId, orgId, requestId, hostname, hostname, imageId, vcpu, memoryMb, proxmoxVmid, status); } + + /** The public identifier of a row this test set up through direct SQL. */ + private UUID pub(String table, long id) { + return SeedFixtures.publicId(jdbcTemplate, table, id); + } } diff --git a/src/test/java/kr/ac/pusan/pickle/admin/AdminInventoryTest.java b/src/test/java/kr/ac/pusan/pickle/admin/AdminInventoryTest.java index 4cfbbd45..e1dd59ff 100644 --- a/src/test/java/kr/ac/pusan/pickle/admin/AdminInventoryTest.java +++ b/src/test/java/kr/ac/pusan/pickle/admin/AdminInventoryTest.java @@ -130,13 +130,13 @@ void adminOsImageListFollowsTheWizardDisplayOrder() throws Exception { @Test void osImageToggleIsSysAdminOnlyAndAuditsRealTransitionsOnly() throws Exception { - mockMvc.perform(patch("/api/v1/admin/os-images/{id}", imageId) + mockMvc.perform(patch("/api/v1/admin/os-images/{id}", pub("os_images", imageId)) .header("Authorization", "Bearer " + sysManagerToken) .contentType(MediaType.APPLICATION_JSON) .content("{\"status\": \"DISABLED\"}")) .andExpect(status().isForbidden()); - mockMvc.perform(patch("/api/v1/admin/os-images/{id}", imageId) + mockMvc.perform(patch("/api/v1/admin/os-images/{id}", pub("os_images", imageId)) .header("Authorization", "Bearer " + sysAdminToken) .contentType(MediaType.APPLICATION_JSON) .content("{\"status\": \"DISABLED\"}")) @@ -145,17 +145,17 @@ void osImageToggleIsSysAdminOnlyAndAuditsRealTransitionsOnly() throws Exception assertThat(jdbcTemplate.queryForObject( "select status from os_images where id = ?", String.class, imageId)) .isEqualTo("DISABLED"); - assertThat(auditCount("os_image.status_update", imageId)).isEqualTo(1); + assertThat(auditCount("os_image.status_update", "os_images", imageId)).isEqualTo(1); // idempotent re-application: 200, no extra audit row - mockMvc.perform(patch("/api/v1/admin/os-images/{id}", imageId) + mockMvc.perform(patch("/api/v1/admin/os-images/{id}", pub("os_images", imageId)) .header("Authorization", "Bearer " + sysAdminToken) .contentType(MediaType.APPLICATION_JSON) .content("{\"status\": \"DISABLED\"}")) .andExpect(status().isOk()); - assertThat(auditCount("os_image.status_update", imageId)).isEqualTo(1); + assertThat(auditCount("os_image.status_update", "os_images", imageId)).isEqualTo(1); - mockMvc.perform(patch("/api/v1/admin/os-images/999999") + mockMvc.perform(patch("/api/v1/admin/os-images/" + SeedFixtures.UNKNOWN_ID) .header("Authorization", "Bearer " + sysAdminToken) .contentType(MediaType.APPLICATION_JSON) .content("{\"status\": \"ACTIVE\"}")) @@ -182,11 +182,12 @@ insert into workspace_members (workspace_id, user_id, role) .header("Authorization", "Bearer " + jwtService.createAccessToken(requester)) .contentType(MediaType.APPLICATION_JSON) .content(""" - {"type": "VM", "workspaceId": %d, "orgId": %d, + {"type": "VM", "workspaceId": "%s", "orgId": "%s", "purpose": "은퇴 OS 이미지 거부 확인", - "vm": {"imageId": %d, "flavorId": %d, "reqVcpu": 2, + "vm": {"imageId": "%s", "flavorId": "%s", "reqVcpu": 2, "reqMemoryMb": 2048, "reqDiskGb": 20}} - """.formatted(workspaceId, orgId, imageId, flavorId))) + """.formatted(pub("workspaces", workspaceId), pub("orgs", orgId), + pub("os_images", imageId), pub("vm_flavors", flavorId)))) .andExpect(status().isUnprocessableContent()) .andExpect(jsonPath("$.errors[0].field").value("vm.imageId")); } @@ -197,13 +198,13 @@ void nodeStatusTransitionIsSysAdminOnlyAndAudited() throws Exception { String original = jdbcTemplate.queryForObject( "select status from nodes where id = ?", String.class, nodeId); try { - mockMvc.perform(patch("/api/v1/admin/nodes/{id}", nodeId) + mockMvc.perform(patch("/api/v1/admin/nodes/{id}", pub("nodes", nodeId)) .header("Authorization", "Bearer " + sysManagerToken) .contentType(MediaType.APPLICATION_JSON) .content("{\"status\": \"MAINTENANCE\"}")) .andExpect(status().isForbidden()); - mockMvc.perform(patch("/api/v1/admin/nodes/{id}", nodeId) + mockMvc.perform(patch("/api/v1/admin/nodes/{id}", pub("nodes", nodeId)) .header("Authorization", "Bearer " + sysAdminToken) .contentType(MediaType.APPLICATION_JSON) .content("{\"status\": \"MAINTENANCE\"}")) @@ -213,7 +214,7 @@ void nodeStatusTransitionIsSysAdminOnlyAndAudited() throws Exception { assertThat(jdbcTemplate.queryForObject( "select status from nodes where id = ?", String.class, nodeId)) .isEqualTo("MAINTENANCE"); - assertThat(auditCount("node.status_update", nodeId)).isEqualTo(1); + assertThat(auditCount("node.status_update", "nodes", nodeId)).isEqualTo(1); } finally { // the node pool is shared across test classes — put the row back jdbcTemplate.update("update nodes set status = ?::node_status where id = ?", @@ -223,8 +224,8 @@ void nodeStatusTransitionIsSysAdminOnlyAndAudited() throws Exception { // ── fixtures ─────────────────────────────────────────────────────────── - private static String byId(long id) { - return "$[?(@.id == %d)]".formatted(id); + private String byId(long id) { + return "$[?(@.id == '%s')]".formatted(pub("os_images", id)); } private String insertImage(long nodeId, String family, String version, String status) { @@ -237,10 +238,10 @@ insert into os_images (name, display_name, os_family, os_version, ssh_username, return name; } - private long auditCount(String action, long targetId) { + private long auditCount(String action, String table, long targetId) { return jdbcTemplate.queryForObject( "select count(*) from audit_logs where action = ? and target_id = ?", - Long.class, action, targetId); + Long.class, action, pub(table, targetId).toString()); } private User ensureUser(String email, UserRole role) { @@ -252,4 +253,9 @@ private User ensureUser(String email, UserRole role) { return userRepository.save(user); }); } + + /** The public identifier of a row this test set up through direct SQL. */ + private UUID pub(String table, long id) { + return SeedFixtures.publicId(jdbcTemplate, table, id); + } } diff --git a/src/test/java/kr/ac/pusan/pickle/admin/AdminIpAllocationsTest.java b/src/test/java/kr/ac/pusan/pickle/admin/AdminIpAllocationsTest.java index 6250fb40..20ef0ead 100644 --- a/src/test/java/kr/ac/pusan/pickle/admin/AdminIpAllocationsTest.java +++ b/src/test/java/kr/ac/pusan/pickle/admin/AdminIpAllocationsTest.java @@ -80,31 +80,31 @@ void listsAllocationsWithPoolAndVmContext() throws Exception { .andExpect(jsonPath(byId(allocated)).exists()) .andExpect(jsonPath(byId(released)).exists()) .andExpect(jsonPath(byId(inOtherPool)).exists()) - .andExpect(jsonPath(byId(allocated) + ".poolId").value((int) poolId)) + .andExpect(jsonPath(byId(allocated) + ".poolId").value(pub("ip_pools", poolId).toString())) .andExpect(jsonPath(byId(allocated) + ".poolName").value("guest-private")) .andExpect(jsonPath(byId(allocated) + ".ip").isNotEmpty()) - .andExpect(jsonPath(byId(allocated) + ".vmId").value((int) vmId)) + .andExpect(jsonPath(byId(allocated) + ".vmId").value(pub("vms", vmId).toString())) .andExpect(jsonPath(byId(allocated) + ".vmName").isNotEmpty()) .andExpect(jsonPath(byId(allocated) + ".hostname").isNotEmpty()) .andExpect(jsonPath(byId(allocated) + ".status").value("ALLOCATED")) .andExpect(jsonPath(byId(allocated) + ".allocatedAt").isNotEmpty()) .andExpect(jsonPath(byId(released) + ".status").value("RELEASED")) .andExpect(jsonPath(byId(released) + ".releasedAt").isNotEmpty()) - .andExpect(jsonPath("$.content[?(@.id == %d && @.vmId == null)]" - .formatted(inOtherPool)).exists()); + .andExpect(jsonPath("$.content[?(@.id == '%s' && @.vmId == null)]" + .formatted(pub("ip_allocations", inOtherPool))).exists()); } @Test void filtersByPoolAndStatus() throws Exception { - mockMvc.perform(get("/api/v1/admin/ip-allocations?poolId=" + otherPoolId) + mockMvc.perform(get("/api/v1/admin/ip-allocations?poolId=" + pub("ip_pools", otherPoolId)) .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isOk()) .andExpect(jsonPath(byId(inOtherPool)).exists()) .andExpect(jsonPath(byId(allocated)).doesNotExist()) .andExpect(jsonPath(byId(released)).doesNotExist()); - mockMvc.perform(get("/api/v1/admin/ip-allocations?poolId=%d&status=RELEASED" - .formatted(poolId)) + mockMvc.perform(get("/api/v1/admin/ip-allocations?poolId=%s&status=RELEASED" + .formatted(pub("ip_pools", poolId))) .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isOk()) .andExpect(jsonPath(byId(released)).exists()) @@ -117,8 +117,8 @@ void filtersByPoolAndStatus() throws Exception { // --- fixtures --------------------------------------------------------------- - private static String byId(long id) { - return "$.content[?(@.id == %d)]".formatted(id); + private String byId(long id) { + return "$.content[?(@.id == '%s')]".formatted(pub("ip_allocations", id)); } private long createPool() { @@ -163,4 +163,9 @@ insert into vms (node_id, workspace_id, org_id, request_id, name, hostname, returning id """, Long.class, nodeId, workspaceId, orgId, requestId, hostname, hostname, imageId); } + + /** The public identifier of a row this test set up through direct SQL. */ + private UUID pub(String table, long id) { + return SeedFixtures.publicId(jdbcTemplate, table, id); + } } diff --git a/src/test/java/kr/ac/pusan/pickle/admin/AdminNodeMetricsServiceTest.java b/src/test/java/kr/ac/pusan/pickle/admin/AdminNodeMetricsServiceTest.java index 84400938..52ca0775 100644 --- a/src/test/java/kr/ac/pusan/pickle/admin/AdminNodeMetricsServiceTest.java +++ b/src/test/java/kr/ac/pusan/pickle/admin/AdminNodeMetricsServiceTest.java @@ -12,6 +12,7 @@ import java.time.ZoneOffset; import java.util.List; import java.util.Optional; +import java.util.UUID; import kr.ac.pusan.pickle.admin.dto.NodeMetricsResponse; import kr.ac.pusan.pickle.common.error.ApiException; import kr.ac.pusan.pickle.common.error.ErrorCodes; @@ -37,6 +38,10 @@ @ExtendWith(MockitoExtension.class) class AdminNodeMetricsServiceTest { + private static final UUID NODE_ID = UUID.fromString("00000000-0000-4000-8000-000000000003"); + private static final UUID UNKNOWN_NODE_ID = + UUID.fromString("00000000-0000-4000-8000-000000000404"); + @Mock private NodeRepository nodeRepository; @Mock @@ -45,7 +50,7 @@ class AdminNodeMetricsServiceTest { @Test void rrdRowsBecomePointsAndTheTimelessOnesAreDropped() { Node node = node(); - when(nodeRepository.findById(3L)).thenReturn(Optional.of(node)); + when(nodeRepository.findByPublicId(NODE_ID)).thenReturn(Optional.of(node)); when(proxmoxClient.nodeRrdData(anyString(), anyString(), any())) .thenReturn(List.of( new NodeRrdSample(null, 0.5, 32.0, 0.01, 1.0, 1.0e10, 5.0e9, 4.0e9, @@ -54,7 +59,7 @@ void rrdRowsBecomePointsAndTheTimelessOnesAreDropped() { 4.0e9, 0.0, 0.0, 1.0e11, 2.0e10, 10.0, null))); NodeMetricsResponse response = new AdminNodeMetricsService(nodeRepository, proxmoxClient, - clock()).metrics(3L, RrdTimeframe.HOUR); + clock()).metrics(NODE_ID, RrdTimeframe.HOUR); assertThat(response.timeframe()).isEqualTo("HOUR"); assertThat(response.points()).hasSize(1); @@ -67,7 +72,7 @@ void rrdRowsBecomePointsAndTheTimelessOnesAreDropped() { @Test void aHypervisorThatRefusesIsA503() { Node node = node(); - when(nodeRepository.findById(3L)).thenReturn(Optional.of(node)); + when(nodeRepository.findByPublicId(NODE_ID)).thenReturn(Optional.of(node)); when(proxmoxClient.nodeRrdData(anyString(), anyString(), any())) .thenThrow(new ProxmoxApiException(596, "no such resource", "GET rrddata")); @@ -77,7 +82,7 @@ void aHypervisorThatRefusesIsA503() { @Test void anUnconfiguredApiTokenIsTheSameOutageAsARefusal() { Node node = node(); - when(nodeRepository.findById(3L)).thenReturn(Optional.of(node)); + when(nodeRepository.findByPublicId(NODE_ID)).thenReturn(Optional.of(node)); // The client refuses before the request leaves when the deployment // carries no PVE token — a state the configuration explicitly allows. when(proxmoxClient.nodeRrdData(anyString(), anyString(), any())) @@ -88,9 +93,9 @@ void anUnconfiguredApiTokenIsTheSameOutageAsARefusal() { @Test void anUnknownNodeIsA404AndIsNeverAskedAbout() { - when(nodeRepository.findById(404L)).thenReturn(Optional.empty()); + when(nodeRepository.findByPublicId(UNKNOWN_NODE_ID)).thenReturn(Optional.empty()); - assertThatThrownBy(() -> service().metrics(404L, RrdTimeframe.HOUR)) + assertThatThrownBy(() -> service().metrics(UNKNOWN_NODE_ID, RrdTimeframe.HOUR)) .isInstanceOfSatisfying(ApiException.class, ex -> { assertThat(ex.getStatus()).isEqualTo(HttpStatus.NOT_FOUND); assertThat(ex.getCode()).isEqualTo(ErrorCodes.RESOURCE_NOT_FOUND); @@ -98,7 +103,7 @@ void anUnknownNodeIsA404AndIsNeverAskedAbout() { } private void assertMetricsUnavailable() { - assertThatThrownBy(() -> service().metrics(3L, RrdTimeframe.HOUR)) + assertThatThrownBy(() -> service().metrics(NODE_ID, RrdTimeframe.HOUR)) .isInstanceOfSatisfying(ApiException.class, ex -> { assertThat(ex.getStatus()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); assertThat(ex.getCode()).isEqualTo(ErrorCodes.METRICS_UNAVAILABLE); diff --git a/src/test/java/kr/ac/pusan/pickle/admin/AdminNodesTest.java b/src/test/java/kr/ac/pusan/pickle/admin/AdminNodesTest.java index 0a379d6b..affcc23c 100644 --- a/src/test/java/kr/ac/pusan/pickle/admin/AdminNodesTest.java +++ b/src/test/java/kr/ac/pusan/pickle/admin/AdminNodesTest.java @@ -110,7 +110,7 @@ insert into nodes (name, api_host, status, cpu_threads, memory_mb, createVm(nodeId, "STOPPED", 2, 2048); createVm(nodeId, "ERROR", 4, 4096); - String byId = "$[?(@.id == %d)]".formatted(nodeId); + String byId = "$[?(@.id == '%s')]".formatted(pub("nodes", nodeId)); mockMvc.perform(get("/api/v1/admin/nodes").header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isOk()) .andExpect(jsonPath(byId + ".status").value(Matchers.hasItem("MAINTENANCE"))) @@ -128,7 +128,7 @@ insert into nodes (name, api_host, status, cpu_threads, memory_mb, .andExpect(jsonPath(byId + ".cpuWarnThreshold").value(Matchers.hasItem(3.0))) .andExpect(jsonPath(byId + ".memoryWarnThreshold").value(Matchers.hasItem(0.8))) .andExpect(jsonPath(byId + ".ipPool.id") - .value(Matchers.hasItem(Math.toIntExact(poolId)))) + .value(Matchers.hasItem(pub("ip_pools", poolId).toString()))) .andExpect(jsonPath(byId + ".ipPool.cidr").value(Matchers.hasItem("10.99.0.0/28"))) .andExpect(jsonPath(byId + ".ipPool.allocatedCount").value(Matchers.hasItem(2))) .andExpect(jsonPath(byId + ".ipPool.freeCount").value(Matchers.hasItem(8))); @@ -150,4 +150,9 @@ insert into vms (node_id, workspace_id, org_id, request_id, name, hostname, values (?, ?, ?, ?, ?, ?, ?, ?, ?, 10, ?::vm_status) """, nodeId, workspaceId, orgId, requestId, slug, slug, imageId, vcpu, memoryMb, status); } + + /** The public identifier of a row this test set up through direct SQL. */ + private UUID pub(String table, long id) { + return SeedFixtures.publicId(jdbcTemplate, table, id); + } } diff --git a/src/test/java/kr/ac/pusan/pickle/admin/AdminNotificationsTest.java b/src/test/java/kr/ac/pusan/pickle/admin/AdminNotificationsTest.java index 77639084..9c71303f 100644 --- a/src/test/java/kr/ac/pusan/pickle/admin/AdminNotificationsTest.java +++ b/src/test/java/kr/ac/pusan/pickle/admin/AdminNotificationsTest.java @@ -85,7 +85,7 @@ void listsTheDeliveryLogWithFiltersAndShape() throws Exception { .andExpect(jsonPath("$.content.length()").value(2)) .andExpect(jsonPath(byId(failed)).exists()) .andExpect(jsonPath(byId(sent)).exists()) - .andExpect(jsonPath(byId(failed) + ".userId").value((int) recipientId)) + .andExpect(jsonPath(byId(failed) + ".userId").value(pub("users", recipientId).toString())) .andExpect(jsonPath(byId(failed) + ".userEmail").value(recipientEmail)) .andExpect(jsonPath(byId(failed) + ".channel").value("EMAIL")) .andExpect(jsonPath(byId(failed) + ".status").value("FAILED")) @@ -119,7 +119,7 @@ void resendIsCasFromFailedOnlyAndAudited() throws Exception { "update notifications set next_attempt_at = now() + interval '1 day' where id = ?", failed); - mockMvc.perform(post("/api/v1/admin/notifications/{id}/resend", failed) + mockMvc.perform(post("/api/v1/admin/notifications/{id}/resend", pub("notifications", failed)) .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isAccepted()) .andExpect(jsonPath("$.message").isNotEmpty()); @@ -134,33 +134,33 @@ void resendIsCasFromFailedOnlyAndAudited() throws Exception { assertThat(jdbcTemplate.queryForObject(""" select count(*) from audit_logs where action = 'notification.resend' and target_id = ? - """, Long.class, failed)).isEqualTo(1); + """, Long.class, pub("notifications", failed).toString())).isEqualTo(1); // now PENDING → a second resend answers 409 (CAS idempotence) - mockMvc.perform(post("/api/v1/admin/notifications/{id}/resend", failed) + mockMvc.perform(post("/api/v1/admin/notifications/{id}/resend", pub("notifications", failed).toString()) .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isConflict()) .andExpect(jsonPath("$.code").value("NOTIFICATION_NOT_RESENDABLE")); - mockMvc.perform(post("/api/v1/admin/notifications/{id}/resend", sent) + mockMvc.perform(post("/api/v1/admin/notifications/{id}/resend", pub("notifications", sent)) .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isConflict()) .andExpect(jsonPath("$.code").value("NOTIFICATION_NOT_RESENDABLE")); - mockMvc.perform(post("/api/v1/admin/notifications/999999/resend") + mockMvc.perform(post("/api/v1/admin/notifications/" + SeedFixtures.UNKNOWN_ID + "/resend") .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND")); - mockMvc.perform(post("/api/v1/admin/notifications/{id}/resend", failed) + mockMvc.perform(post("/api/v1/admin/notifications/{id}/resend", pub("notifications", failed)) .header("Authorization", "Bearer " + orgAdminToken)) .andExpect(status().isForbidden()); } // ── fixtures ─────────────────────────────────────────────────────────── - private static String byId(long id) { - return "$.content[?(@.id == %d)]".formatted(id); + private String byId(long id) { + return "$.content[?(@.id == '%s')]".formatted(pub("notifications", id)); } private long insertNotification(long userId, String event, String status, String lastError) { @@ -172,4 +172,9 @@ insert into notifications (user_id, event, title, body, importance, status, returning id """, Long.class, userId, event, status, lastError, status); } + + /** The public identifier of a row this test set up through direct SQL. */ + private UUID pub(String table, long id) { + return SeedFixtures.publicId(jdbcTemplate, table, id); + } } diff --git a/src/test/java/kr/ac/pusan/pickle/admin/AdminOrgUserTest.java b/src/test/java/kr/ac/pusan/pickle/admin/AdminOrgUserTest.java index b5ebca96..4b3abb60 100644 --- a/src/test/java/kr/ac/pusan/pickle/admin/AdminOrgUserTest.java +++ b/src/test/java/kr/ac/pusan/pickle/admin/AdminOrgUserTest.java @@ -10,10 +10,12 @@ import java.time.Instant; import java.util.Map; +import java.util.UUID; import kr.ac.pusan.pickle.orgs.Org; import kr.ac.pusan.pickle.orgs.OrgRepository; import kr.ac.pusan.pickle.security.JwtService; import kr.ac.pusan.pickle.support.EmbeddedPostgresConfig; +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.UserRole; @@ -69,8 +71,8 @@ class AdminOrgUserTest { @BeforeEach void setUp() { - Org org = orgRepository.findBySlug("adm-org-base").orElseGet( - () -> orgRepository.save(new Org("관리 테스트 기관", "adm-org-base", null))); + Org org = orgRepository.findFirstByNameOrderByIdAsc("관리 테스트 기관").orElseGet( + () -> orgRepository.save(new Org("관리 테스트 기관", null))); sysAdmin = ensureUser("adm.sysadmin@pusan.ac.kr", "시스템관리자", UserRole.SYS_ADMIN, null); orgAdmin = ensureUser("adm.orgadmin@pusan.ac.kr", "기관관리자", UserRole.ORG_ADMIN, org.getId()); regularUser = ensureUser("adm.user@pusan.ac.kr", "학생", UserRole.USER, null); @@ -81,9 +83,9 @@ void setUp() { @Test void adminOrgListShowsEveryStatusToTheSysTier() throws Exception { - String disabledSlug = "adm-org-disabled"; - Org disabled = orgRepository.findBySlug(disabledSlug).orElseGet( - () -> orgRepository.save(new Org("비활성 기관", disabledSlug, null))); + String disabledName = "관리 비활성 기관"; + Org disabled = orgRepository.findFirstByNameOrderByIdAsc(disabledName).orElseGet( + () -> orgRepository.save(new Org(disabledName, null))); jdbcTemplate.update( "update orgs set status = 'DISABLED'::org_status, hidden = true where id = ?", disabled.getId()); @@ -110,13 +112,13 @@ void adminOrgListShowsEveryStatusToTheSysTier() throws Exception { .andExpect(status().isForbidden()); } - private static String byId(long orgId) { - return "$[?(@.id == %d)]".formatted(orgId); + private String byId(long orgId) { + return "$[?(@.id == '%s')]".formatted(pub("orgs", orgId)); } @Test void adminEndpointsAreSysAdminOnly() throws Exception { - Map body = Map.of("name", "새 기관", "slug", "adm-gate-x1"); + Map body = Map.of("name", "새 기관"); // ORG_ADMIN and USER are rejected with 403 ACCESS_DENIED postJson("/api/v1/admin/orgs", orgAdminToken, body) .andExpect(status().isForbidden()) @@ -125,7 +127,7 @@ void adminEndpointsAreSysAdminOnly() throws Exception { postJson("/api/v1/admin/orgs", userToken, body) .andExpect(status().isForbidden()) .andExpect(jsonPath("$.code").value("ACCESS_DENIED")); - patchJson("/api/v1/admin/users/" + regularUser.getId(), orgAdminToken, Map.of("role", "SYS_ADMIN")) + patchJson("/api/v1/admin/users/" + regularUser.getPublicId(), orgAdminToken, Map.of("role", "SYS_ADMIN")) .andExpect(status().isForbidden()) .andExpect(jsonPath("$.code").value("ACCESS_DENIED")); mockMvc.perform(post("/api/v1/admin/orgs").contentType(MediaType.APPLICATION_JSON) @@ -136,26 +138,22 @@ void adminEndpointsAreSysAdminOnly() throws Exception { @Test void orgCreateAndUpdateLifecycle() throws Exception { postJson("/api/v1/admin/orgs", sysAdminToken, - Map.of("name", "정보컴퓨터공학부 실습지원센터", "slug", "adm-org-x1", "description", "실습 자원 제공")) + Map.of("name", "정보컴퓨터공학부 실습지원센터", "description", "실습 자원 제공")) .andExpect(status().isCreated()) .andExpect(jsonPath("$.name").value("정보컴퓨터공학부 실습지원센터")) - .andExpect(jsonPath("$.slug").value("adm-org-x1")) + .andExpect(jsonPath("$.id").isNotEmpty()) .andExpect(jsonPath("$.status").value("ACTIVE")) .andExpect(jsonPath("$.createdAt").isNotEmpty()); - long orgId = orgRepository.findBySlug("adm-org-x1").orElseThrow().getId(); + long orgId = orgRepository.findFirstByNameOrderByIdAsc("정보컴퓨터공학부 실습지원센터").orElseThrow().getId(); - // duplicate slug → 409 ORG_SLUG_DUPLICATE - postJson("/api/v1/admin/orgs", sysAdminToken, Map.of("name", "다른 기관", "slug", "adm-org-x1")) - .andExpect(status().isConflict()) - .andExpect(jsonPath("$.code").value("ORG_SLUG_DUPLICATE")); - - // invalid slug → 422 - postJson("/api/v1/admin/orgs", sysAdminToken, Map.of("name", "기관", "slug", "Bad_Slug!")) + // A blank name is still refused; there is no other uniqueness left to + // check, the slug having gone with the sequential ids (V78). + postJson("/api/v1/admin/orgs", sysAdminToken, Map.of("name", " ")) .andExpect(status().isUnprocessableContent()) - .andExpect(jsonPath("$.errors[0].field").value("slug")); + .andExpect(jsonPath("$.errors[0].field").value("name")); // patch name + status - patchJson("/api/v1/admin/orgs/" + orgId, sysAdminToken, + patchJson("/api/v1/admin/orgs/" + pub("orgs", orgId), sysAdminToken, Map.of("name", "실습지원센터(개편)", "status", "DISABLED")) .andExpect(status().isOk()) .andExpect(jsonPath("$.name").value("실습지원센터(개편)")) @@ -164,38 +162,38 @@ void orgCreateAndUpdateLifecycle() throws Exception { // DISABLED orgs disappear from the user-facing reference list mockMvc.perform(get("/api/v1/orgs").header("Authorization", "Bearer " + userToken)) .andExpect(status().isOk()) - .andExpect(jsonPath("$[?(@.slug == 'adm-org-x1')]").isEmpty()); + .andExpect(jsonPath("$[?(@.name == '실습지원센터(개편)')]").isEmpty()); // hidden toggle (v0.15.0): back to ACTIVE but hidden — USER list still // filters it, manager tier sees it with hidden=true - patchJson("/api/v1/admin/orgs/" + orgId, sysAdminToken, + patchJson("/api/v1/admin/orgs/" + pub("orgs", orgId), sysAdminToken, Map.of("status", "ACTIVE", "hidden", true)) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value("ACTIVE")) .andExpect(jsonPath("$.hidden").value(true)); mockMvc.perform(get("/api/v1/orgs").header("Authorization", "Bearer " + userToken)) .andExpect(status().isOk()) - .andExpect(jsonPath("$[?(@.slug == 'adm-org-x1')]").isEmpty()); + .andExpect(jsonPath("$[?(@.name == '실습지원센터(개편)')]").isEmpty()); mockMvc.perform(get("/api/v1/orgs").header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isOk()) - .andExpect(jsonPath("$[?(@.slug == 'adm-org-x1')].hidden") + .andExpect(jsonPath("$[?(@.name == '실습지원센터(개편)')].hidden") .value(org.hamcrest.Matchers.contains(true))); - patchJson("/api/v1/admin/orgs/" + orgId, sysAdminToken, Map.of("hidden", false)) + patchJson("/api/v1/admin/orgs/" + pub("orgs", orgId), sysAdminToken, Map.of("hidden", false)) .andExpect(status().isOk()) .andExpect(jsonPath("$.hidden").value(false)); // empty patch → 422, unknown org → 404 - patchJson("/api/v1/admin/orgs/" + orgId, sysAdminToken, Map.of()) + patchJson("/api/v1/admin/orgs/" + pub("orgs", orgId), sysAdminToken, Map.of()) .andExpect(status().isUnprocessableContent()) .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); - patchJson("/api/v1/admin/orgs/999999", sysAdminToken, Map.of("name", "유령 기관")) + patchJson("/api/v1/admin/orgs/" + SeedFixtures.UNKNOWN_ID, sysAdminToken, Map.of("name", "유령 기관")) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND")); // org.create / org.update audit rows Long audits = jdbcTemplate.queryForObject( "select count(*) from audit_logs where action in ('org.create', 'org.update') and target_id = ?", - Long.class, orgId); + Long.class, pub("orgs", orgId).toString()); assertThat(audits).isGreaterThanOrEqualTo(2); } @@ -204,38 +202,38 @@ void userRoleUpdateValidatesOrgIdAndBumpsTokenVersion() throws Exception { User target = ensureUser("adm.promotee@pusan.ac.kr", "승격대상", UserRole.USER, null); String targetToken = jwtService.createAccessToken(target); int versionBefore = target.getTokenVersion(); - Org org = orgRepository.findBySlug("adm-org-base").orElseThrow(); + Org org = orgRepository.findFirstByNameOrderByIdAsc("관리 테스트 기관").orElseThrow(); // ORG_ADMIN requires orgId → 422 with the field error - patchJson("/api/v1/admin/users/" + target.getId(), sysAdminToken, Map.of("role", "ORG_ADMIN")) + patchJson("/api/v1/admin/users/" + target.getPublicId(), sysAdminToken, Map.of("role", "ORG_ADMIN")) .andExpect(status().isUnprocessableContent()) .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")) .andExpect(jsonPath("$.errors[0].field").value("orgId")); // unknown orgId → 422 - patchJson("/api/v1/admin/users/" + target.getId(), sysAdminToken, - Map.of("role", "ORG_ADMIN", "orgId", 999999)) + patchJson("/api/v1/admin/users/" + target.getPublicId(), sysAdminToken, + Map.of("role", "ORG_ADMIN", "orgId", SeedFixtures.UNKNOWN_ID)) .andExpect(status().isUnprocessableContent()) .andExpect(jsonPath("$.errors[0].field").value("orgId")); // USER/SYS_ADMIN must not carry an orgId → 422 - patchJson("/api/v1/admin/users/" + target.getId(), sysAdminToken, - Map.of("role", "USER", "orgId", org.getId())) + patchJson("/api/v1/admin/users/" + target.getPublicId(), sysAdminToken, + Map.of("role", "USER", "orgId", org.getPublicId())) .andExpect(status().isUnprocessableContent()) .andExpect(jsonPath("$.errors[0].field").value("orgId")); // empty patch → 422, unknown user → 404 - patchJson("/api/v1/admin/users/" + target.getId(), sysAdminToken, Map.of()) + patchJson("/api/v1/admin/users/" + target.getPublicId(), sysAdminToken, Map.of()) .andExpect(status().isUnprocessableContent()); - patchJson("/api/v1/admin/users/999999", sysAdminToken, Map.of("role", "USER")) + patchJson("/api/v1/admin/users/" + SeedFixtures.UNKNOWN_ID, sysAdminToken, Map.of("role", "USER")) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND")); // promotion works and returns the contract UserSummary - patchJson("/api/v1/admin/users/" + target.getId(), sysAdminToken, - Map.of("role", "ORG_ADMIN", "orgId", org.getId())) + patchJson("/api/v1/admin/users/" + target.getPublicId(), sysAdminToken, + Map.of("role", "ORG_ADMIN", "orgId", org.getPublicId())) .andExpect(status().isOk()) - .andExpect(jsonPath("$.id").value(target.getId())) + .andExpect(jsonPath("$.id").value(target.getPublicId().toString())) .andExpect(jsonPath("$.email").value(target.getEmail())) .andExpect(jsonPath("$.role").value("ORG_ADMIN")); @@ -248,10 +246,10 @@ void userRoleUpdateValidatesOrgIdAndBumpsTokenVersion() throws Exception { .andExpect(jsonPath("$.code").value("AUTH_TOKEN_INVALID")); // orgId-only change keeps the role and does NOT bump token_version - postJson("/api/v1/admin/orgs", sysAdminToken, Map.of("name", "이관 기관", "slug", "adm-org-x2")) + postJson("/api/v1/admin/orgs", sysAdminToken, Map.of("name", "이관 기관")) .andExpect(status().isCreated()); - long secondOrgId = orgRepository.findBySlug("adm-org-x2").orElseThrow().getId(); - patchJson("/api/v1/admin/users/" + target.getId(), sysAdminToken, Map.of("orgId", secondOrgId)) + long secondOrgId = orgRepository.findFirstByNameOrderByIdAsc("이관 기관").orElseThrow().getId(); + patchJson("/api/v1/admin/users/" + target.getPublicId(), sysAdminToken, Map.of("orgId", pub("orgs", secondOrgId))) .andExpect(status().isOk()) .andExpect(jsonPath("$.role").value("ORG_ADMIN")); reloaded = userRepository.findById(target.getId()).orElseThrow(); @@ -261,7 +259,7 @@ void userRoleUpdateValidatesOrgIdAndBumpsTokenVersion() throws Exception { // user.role_update audit rows Long audits = jdbcTemplate.queryForObject( "select count(*) from audit_logs where action = 'user.role_update' and target_id = ?", - Long.class, target.getId()); + Long.class, target.getPublicId().toString()); assertThat(audits).isGreaterThanOrEqualTo(2); } @@ -289,4 +287,9 @@ private User ensureUser(String email, String name, UserRole role, Long orgId) { return userRepository.save(user); }); } + + /** The public identifier of a row this test set up through direct SQL. */ + private UUID pub(String table, long id) { + return SeedFixtures.publicId(jdbcTemplate, table, id); + } } diff --git a/src/test/java/kr/ac/pusan/pickle/admin/AdminSummariesTest.java b/src/test/java/kr/ac/pusan/pickle/admin/AdminSummariesTest.java index 4b8afdd9..d2c78d09 100644 --- a/src/test/java/kr/ac/pusan/pickle/admin/AdminSummariesTest.java +++ b/src/test/java/kr/ac/pusan/pickle/admin/AdminSummariesTest.java @@ -71,7 +71,7 @@ class AdminSummariesTest { @BeforeEach void setUp() { String slug = "ads-" + UUID.randomUUID().toString().substring(0, 8); - org = orgRepository.save(new Org("요약 테스트 기관 " + slug, slug, null)); + org = orgRepository.save(new Org("요약 테스트 기관 " + slug, null)); User orgAdmin = createUser("ads.admin." + slug + "@pusan.ac.kr", UserRole.ORG_ADMIN, org.getId()); User regularUser = createUser("ads.user." + slug + "@pusan.ac.kr", UserRole.USER, null); @@ -126,9 +126,9 @@ insert into routes (domain_id, target_port, protocol, status, generation) .andExpect(jsonPath("$.resource.allocatedMemoryMb").value(6144)) .andExpect(jsonPath("$.resource.allocatedDiskGb").value(30)) .andExpect(jsonPath("$.resource.guidance").isNotEmpty()) - .andExpect(jsonPath("$.topWorkspacesByVmCount[0].workspaceId").value(workspaceBig)) + .andExpect(jsonPath("$.topWorkspacesByVmCount[0].workspaceId").value(pub("workspaces", workspaceBig).toString())) .andExpect(jsonPath("$.topWorkspacesByVmCount[0].vmCount").value(2)) - .andExpect(jsonPath("$.topWorkspacesByVmCount[1].workspaceId").value(workspaceSmall)) + .andExpect(jsonPath("$.topWorkspacesByVmCount[1].workspaceId").value(pub("workspaces", workspaceSmall).toString())) .andExpect(jsonPath("$.topWorkspacesByVmCount[1].vmCount").value(1)) .andExpect(jsonPath("$.publishedServiceCount").value(1)) .andExpect(jsonPath("$.expiringVmCount30d").value(1)) @@ -146,19 +146,19 @@ void orgSummaryScopingFollowsThe404MaskConvention() throws Exception { .andExpect(status().isForbidden()); // ORG_ADMIN: own org implicitly or explicitly, other org → 404 - mockMvc.perform(get("/api/v1/admin/summary?orgId=" + org.getId()) + mockMvc.perform(get("/api/v1/admin/summary?orgId=" + org.getPublicId()) .header("Authorization", "Bearer " + orgAdminToken)) .andExpect(status().isOk()); - mockMvc.perform(get("/api/v1/admin/summary?orgId=" + otherOrgId) + mockMvc.perform(get("/api/v1/admin/summary?orgId=" + pub("orgs", otherOrgId)) .header("Authorization", "Bearer " + orgAdminToken)) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND")); // SYS_ADMIN drills into a named org; an unknown org → 404 - mockMvc.perform(get("/api/v1/admin/summary?orgId=" + org.getId()) + mockMvc.perform(get("/api/v1/admin/summary?orgId=" + org.getPublicId()) .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isOk()); - mockMvc.perform(get("/api/v1/admin/summary?orgId=999999") + mockMvc.perform(get("/api/v1/admin/summary?orgId=" + SeedFixtures.UNKNOWN_ID) .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isNotFound()); @@ -266,4 +266,9 @@ insert into vms (node_id, workspace_id, org_id, request_id, name, hostname, """, Long.class, nodeId, workspaceId, org.getId(), requestId, hostname, hostname, imageId, status, endDate); } + + /** The public identifier of a row this test set up through direct SQL. */ + private UUID pub(String table, long id) { + return SeedFixtures.publicId(jdbcTemplate, table, id); + } } diff --git a/src/test/java/kr/ac/pusan/pickle/admin/AdminTasksTest.java b/src/test/java/kr/ac/pusan/pickle/admin/AdminTasksTest.java index 3905a15e..834987e3 100644 --- a/src/test/java/kr/ac/pusan/pickle/admin/AdminTasksTest.java +++ b/src/test/java/kr/ac/pusan/pickle/admin/AdminTasksTest.java @@ -84,15 +84,15 @@ void listsTasksWithVmAndOrgContextAndFilters() throws Exception { .andExpect(jsonPath(byTaskId(needsAdmin)).exists()) .andExpect(jsonPath(byTaskId(done)).exists()); - mockMvc.perform(get("/api/v1/admin/tasks?status=NEEDS_ADMIN&vmId=" + vmA) + mockMvc.perform(get("/api/v1/admin/tasks?status=NEEDS_ADMIN&vmId=" + pub("vms", vmA)) .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isOk()) .andExpect(jsonPath("$.content.length()").value(1)) - .andExpect(jsonPath("$.content[0].taskId").value(needsAdmin)) - .andExpect(jsonPath("$.content[0].vmId").value(vmA)) + .andExpect(jsonPath("$.content[0].taskId").value(pub("provisioning_tasks", needsAdmin).toString())) + .andExpect(jsonPath("$.content[0].vmId").value(pub("vms", vmA).toString())) .andExpect(jsonPath("$.content[0].vmName").isNotEmpty()) .andExpect(jsonPath("$.content[0].hostname").isNotEmpty()) - .andExpect(jsonPath("$.content[0].orgId").value(orgId)) + .andExpect(jsonPath("$.content[0].orgId").value(pub("orgs", orgId).toString())) .andExpect(jsonPath("$.content[0].orgName").value(orgName)) .andExpect(jsonPath("$.content[0].workspaceName").isNotEmpty()) .andExpect(jsonPath("$.content[0].kind").value("PROVISION")) @@ -104,7 +104,7 @@ void listsTasksWithVmAndOrgContextAndFilters() throws Exception { .andExpect(jsonPath("$.content[0].createdAt").isNotEmpty()) .andExpect(jsonPath("$.content[0].updatedAt").isNotEmpty()); - mockMvc.perform(get("/api/v1/admin/tasks?kind=DELETE&vmId=" + vmB) + mockMvc.perform(get("/api/v1/admin/tasks?kind=DELETE&vmId=" + pub("vms", vmB)) .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isOk()) .andExpect(jsonPath(byTaskId(done)).exists()) @@ -123,7 +123,7 @@ void retryIsAcceptedOnlyForNeedsAdminAndResetsTheAttemptBudget() throws Exceptio long parked = createTask(vmA, "PROVISION", "NEEDS_ADMIN", 5, "IP 풀 고갈"); long enqueuedBefore = provisionEnqueueCount(); - mockMvc.perform(post("/api/v1/admin/tasks/{id}/retry", parked) + mockMvc.perform(post("/api/v1/admin/tasks/{id}/retry", pub("provisioning_tasks", parked)) .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isAccepted()) .andExpect(jsonPath("$.message").isNotEmpty()); @@ -142,10 +142,10 @@ void retryIsAcceptedOnlyForNeedsAdminAndResetsTheAttemptBudget() throws Exceptio assertThat(jdbcTemplate.queryForObject( "select count(*) from audit_logs where action = 'task.retry' and target_id = ?", - Long.class, parked)).isEqualTo(1); + Long.class, pub("provisioning_tasks", parked).toString())).isEqualTo(1); // now RETRYING → a second retry answers 409 (idempotence by CAS) - mockMvc.perform(post("/api/v1/admin/tasks/{id}/retry", parked) + mockMvc.perform(post("/api/v1/admin/tasks/{id}/retry", pub("provisioning_tasks", parked).toString()) .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isConflict()) .andExpect(jsonPath("$.code").value("TASK_NOT_RETRYABLE")); @@ -155,25 +155,25 @@ void retryIsAcceptedOnlyForNeedsAdminAndResetsTheAttemptBudget() throws Exceptio void retryAnswers409ForFinishedTasks404ForUnknownAnd403ForOrgAdmin() throws Exception { long doneTask = createTask(vmB, "DELETE", "DONE", 0, null); - mockMvc.perform(post("/api/v1/admin/tasks/{id}/retry", doneTask) + mockMvc.perform(post("/api/v1/admin/tasks/{id}/retry", pub("provisioning_tasks", doneTask)) .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isConflict()) .andExpect(jsonPath("$.code").value("TASK_NOT_RETRYABLE")); - mockMvc.perform(post("/api/v1/admin/tasks/999999/retry") + mockMvc.perform(post("/api/v1/admin/tasks/" + SeedFixtures.UNKNOWN_ID + "/retry") .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND")); - mockMvc.perform(post("/api/v1/admin/tasks/{id}/retry", doneTask) + mockMvc.perform(post("/api/v1/admin/tasks/{id}/retry", pub("provisioning_tasks", doneTask)) .header("Authorization", "Bearer " + orgAdminToken)) .andExpect(status().isForbidden()); } // --- fixtures --------------------------------------------------------------- - private static String byTaskId(long taskId) { - return "$.content[?(@.taskId == %d)]".formatted(taskId); + private String byTaskId(long taskId) { + return "$.content[?(@.taskId == '%s')]".formatted(pub("provisioning_tasks", taskId)); } private long createWorkspace() { @@ -213,4 +213,9 @@ insert into provisioning_tasks (vm_id, kind, current_step, status, attempts, returning id """, Long.class, vmId, kind, currentStep, status, lastError); } + + /** The public identifier of a row this test set up through direct SQL. */ + private UUID pub(String table, long id) { + return SeedFixtures.publicId(jdbcTemplate, table, id); + } } diff --git a/src/test/java/kr/ac/pusan/pickle/admin/AdminUserDisableRevokeTest.java b/src/test/java/kr/ac/pusan/pickle/admin/AdminUserDisableRevokeTest.java index 33c0b110..2ab5dfba 100644 --- a/src/test/java/kr/ac/pusan/pickle/admin/AdminUserDisableRevokeTest.java +++ b/src/test/java/kr/ac/pusan/pickle/admin/AdminUserDisableRevokeTest.java @@ -66,7 +66,7 @@ void disableRevokesLingeringRefreshTokens() throws Exception { assertThat(refreshTokenService.findByRawToken(issued.rawToken()).orElseThrow().isRevoked()) .isFalse(); - mockMvc.perform(post("/api/v1/admin/users/" + target.getId() + "/disable") + mockMvc.perform(post("/api/v1/admin/users/" + target.getPublicId() + "/disable") .header("Authorization", "Bearer " + sysAdminToken) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(Map.of("reason", "세션 무효화 확인")))) diff --git a/src/test/java/kr/ac/pusan/pickle/admin/AdminUsersTest.java b/src/test/java/kr/ac/pusan/pickle/admin/AdminUsersTest.java index 46ac703f..29fec62d 100644 --- a/src/test/java/kr/ac/pusan/pickle/admin/AdminUsersTest.java +++ b/src/test/java/kr/ac/pusan/pickle/admin/AdminUsersTest.java @@ -74,8 +74,8 @@ class AdminUsersTest { @BeforeEach void setUp() { - Org orgA = orgRepository.findBySlug("au-org-a") - .orElseGet(() -> orgRepository.save(new Org("사용자관리 기관 A", "au-org-a", null))); + Org orgA = orgRepository.findFirstByNameOrderByIdAsc("사용자관리 기관 A") + .orElseGet(() -> orgRepository.save(new Org("사용자관리 기관 A", null))); sysAdmin = ensureUser("au.sys@pusan.ac.kr", "시스템", UserRole.SYS_ADMIN, null, UserStatus.ACTIVE); orgAdminA = ensureUser("au.orga@pusan.ac.kr", "기관A관리자", UserRole.ORG_ADMIN, orgA.getId(), UserStatus.ACTIVE); @@ -125,13 +125,13 @@ void listAndDetailAreScopedForOrgAdmin() throws Exception { .andExpect(jsonPath("$.totalElements").value(0)); // ORG_ADMIN detail: in-scope 200, out-of-scope masked as 404 - mockMvc.perform(get("/api/v1/admin/users/" + memberA.getId()) + mockMvc.perform(get("/api/v1/admin/users/" + memberA.getPublicId()) .header("Authorization", "Bearer " + orgAdminAToken)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.id").value(memberA.getId())) + .andExpect(jsonPath("$.id").value(memberA.getPublicId().toString())) .andExpect(jsonPath("$.activeVmCount").value(1)) .andExpect(jsonPath("$.statusChanges").isArray()); - mockMvc.perform(get("/api/v1/admin/users/" + foreign.getId()) + mockMvc.perform(get("/api/v1/admin/users/" + foreign.getPublicId()) .header("Authorization", "Bearer " + orgAdminAToken)) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND")); @@ -149,18 +149,18 @@ void disableAndEnableRestorePreviousStatus() throws Exception { String targetToken = jwtService.createAccessToken(target); // ORG_ADMIN cannot disable (SYS_ADMIN only) - postJson("/api/v1/admin/users/" + target.getId() + "/disable", orgAdminAToken, + postJson("/api/v1/admin/users/" + target.getPublicId() + "/disable", orgAdminAToken, Map.of("reason", "시도")) .andExpect(status().isForbidden()); // self-disable is refused - postJson("/api/v1/admin/users/" + sysAdmin.getId() + "/disable", sysAdminToken, + postJson("/api/v1/admin/users/" + sysAdmin.getPublicId() + "/disable", sysAdminToken, Map.of("reason", "본인")) .andExpect(status().isConflict()) .andExpect(jsonPath("$.code").value("ACCOUNT_SELF_DISABLE_FORBIDDEN")); // disable → 200 DISABLED, target token dies immediately - postJson("/api/v1/admin/users/" + target.getId() + "/disable", sysAdminToken, + postJson("/api/v1/admin/users/" + target.getPublicId() + "/disable", sysAdminToken, Map.of("reason", "자원 남용")) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value("DISABLED")) @@ -170,19 +170,19 @@ void disableAndEnableRestorePreviousStatus() throws Exception { .andExpect(status().isUnauthorized()); // disabling an already-DISABLED account → 409 - postJson("/api/v1/admin/users/" + target.getId() + "/disable", sysAdminToken, + postJson("/api/v1/admin/users/" + target.getPublicId() + "/disable", sysAdminToken, Map.of("reason", "다시")) .andExpect(status().isConflict()) .andExpect(jsonPath("$.code").value("ACCOUNT_INVALID_STATE")); // enable restores ACTIVE and clears the disable stamp - postJson("/api/v1/admin/users/" + target.getId() + "/enable", sysAdminToken, Map.of()) + postJson("/api/v1/admin/users/" + target.getPublicId() + "/enable", sysAdminToken, Map.of()) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value("ACTIVE")) .andExpect(jsonPath("$.disabledAt").value((Object) null)); // enabling a non-DISABLED account → 409 ACCOUNT_NOT_DISABLED - postJson("/api/v1/admin/users/" + target.getId() + "/enable", sysAdminToken, Map.of()) + postJson("/api/v1/admin/users/" + target.getPublicId() + "/enable", sysAdminToken, Map.of()) .andExpect(status().isConflict()) .andExpect(jsonPath("$.code").value("ACCOUNT_NOT_DISABLED")); } @@ -192,11 +192,11 @@ void enableRestoresPendingVerificationNotActive() throws Exception { User pending = ensureUser("au.pending@pusan.ac.kr", "미인증", UserRole.USER, null, UserStatus.PENDING_VERIFICATION); - postJson("/api/v1/admin/users/" + pending.getId() + "/disable", sysAdminToken, + postJson("/api/v1/admin/users/" + pending.getPublicId() + "/disable", sysAdminToken, Map.of("reason", "미인증 잠금")) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value("DISABLED")); - postJson("/api/v1/admin/users/" + pending.getId() + "/enable", sysAdminToken, Map.of()) + postJson("/api/v1/admin/users/" + pending.getPublicId() + "/enable", sysAdminToken, Map.of()) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value("PENDING_VERIFICATION")); } @@ -217,13 +217,13 @@ void mfaEnabledReflectsRealEnrollment() throws Exception { .andExpect(jsonPath("$.content[0].email").value("au.mfa@pusan.ac.kr")) .andExpect(jsonPath("$.content[0].mfaEnabled").value(true)); // … and so does the detail (drives the admin mfa-reset button) - mockMvc.perform(get("/api/v1/admin/users/" + enrolled.getId()) + mockMvc.perform(get("/api/v1/admin/users/" + enrolled.getPublicId()) .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isOk()) .andExpect(jsonPath("$.mfaEnabled").value(true)); // an un-enrolled user stays false in the detail view - mockMvc.perform(get("/api/v1/admin/users/" + foreign.getId()) + mockMvc.perform(get("/api/v1/admin/users/" + foreign.getPublicId()) .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isOk()) .andExpect(jsonPath("$.mfaEnabled").value(false)); diff --git a/src/test/java/kr/ac/pusan/pickle/admin/AdminVmFlavorTest.java b/src/test/java/kr/ac/pusan/pickle/admin/AdminVmFlavorTest.java index 03225655..b3c7175d 100644 --- a/src/test/java/kr/ac/pusan/pickle/admin/AdminVmFlavorTest.java +++ b/src/test/java/kr/ac/pusan/pickle/admin/AdminVmFlavorTest.java @@ -143,7 +143,7 @@ void createIsSysAdminOnlyAndRejectsDuplicateNames() throws Exception { .andExpect(jsonPath("$.diskGb").value(80)) .andExpect(jsonPath("$.status").value("ACTIVE")) .andExpect(jsonPath("$.notes").isNotEmpty()) - .andExpect(jsonPath("$.id").isNumber()); + .andExpect(jsonPath("$.id").isNotEmpty()); long createdId = jdbcTemplate.queryForObject( "select id from vm_flavors where name = ?", Long.class, name); assertThat(auditCount("flavor.create", createdId)).isEqualTo(1); @@ -172,14 +172,14 @@ void createIsSysAdminOnlyAndRejectsDuplicateNames() throws Exception { @Test void patchIsSysAdminOnlyAndAuditsRealChangesOnly() throws Exception { - mockMvc.perform(patch("/api/v1/admin/vm-flavors/{id}", flavorId) + mockMvc.perform(patch("/api/v1/admin/vm-flavors/{id}", pub("vm_flavors", flavorId)) .header("Authorization", "Bearer " + sysManagerToken) .contentType(MediaType.APPLICATION_JSON) .content("{\"vcpu\": 4}")) .andExpect(status().isForbidden()); // partial edit of values and status in one call - mockMvc.perform(patch("/api/v1/admin/vm-flavors/{id}", flavorId) + mockMvc.perform(patch("/api/v1/admin/vm-flavors/{id}", pub("vm_flavors", flavorId)) .header("Authorization", "Bearer " + sysAdminToken) .contentType(MediaType.APPLICATION_JSON) .content(""" @@ -197,7 +197,7 @@ void patchIsSysAdminOnlyAndAuditsRealChangesOnly() throws Exception { assertThat(auditCount("flavor.update", flavorId)).isEqualTo(1); // re-applying the same values changes nothing → 200 without an audit row - mockMvc.perform(patch("/api/v1/admin/vm-flavors/{id}", flavorId) + mockMvc.perform(patch("/api/v1/admin/vm-flavors/{id}", pub("vm_flavors", flavorId)) .header("Authorization", "Bearer " + sysAdminToken) .contentType(MediaType.APPLICATION_JSON) .content("{\"vcpu\": 4, \"status\": \"DISABLED\"}")) @@ -207,7 +207,7 @@ void patchIsSysAdminOnlyAndAuditsRealChangesOnly() throws Exception { // notes are persisted blank-to-null, so a blank note against the // already-null column is a no-op too — no audit row (the javadoc's // idempotency promise held only for the typed fields before) - mockMvc.perform(patch("/api/v1/admin/vm-flavors/{id}", flavorId) + mockMvc.perform(patch("/api/v1/admin/vm-flavors/{id}", pub("vm_flavors", flavorId)) .header("Authorization", "Bearer " + sysAdminToken) .contentType(MediaType.APPLICATION_JSON) .content("{\"notes\": \"\"}")) @@ -215,7 +215,7 @@ void patchIsSysAdminOnlyAndAuditsRealChangesOnly() throws Exception { .andExpect(jsonPath("$.notes").value((Object) null)); assertThat(auditCount("flavor.update", flavorId)).isEqualTo(1); - mockMvc.perform(patch("/api/v1/admin/vm-flavors/{id}", flavorId) + mockMvc.perform(patch("/api/v1/admin/vm-flavors/{id}", pub("vm_flavors", flavorId)) .header("Authorization", "Bearer " + sysAdminToken) .contentType(MediaType.APPLICATION_JSON) .content("{\"notes\": \" \"}")) @@ -224,7 +224,7 @@ void patchIsSysAdminOnlyAndAuditsRealChangesOnly() throws Exception { assertThat(auditCount("flavor.update", flavorId)).isEqualTo(1); // a real note IS a change... - mockMvc.perform(patch("/api/v1/admin/vm-flavors/{id}", flavorId) + mockMvc.perform(patch("/api/v1/admin/vm-flavors/{id}", pub("vm_flavors", flavorId)) .header("Authorization", "Bearer " + sysAdminToken) .contentType(MediaType.APPLICATION_JSON) .content("{\"notes\": \"대형 배치 작업용입니다.\"}")) @@ -234,7 +234,7 @@ void patchIsSysAdminOnlyAndAuditsRealChangesOnly() throws Exception { // ...but re-sending it with surrounding whitespace stores the same // stripped value, so it is a no-op as well - mockMvc.perform(patch("/api/v1/admin/vm-flavors/{id}", flavorId) + mockMvc.perform(patch("/api/v1/admin/vm-flavors/{id}", pub("vm_flavors", flavorId)) .header("Authorization", "Bearer " + sysAdminToken) .contentType(MediaType.APPLICATION_JSON) .content("{\"notes\": \" 대형 배치 작업용입니다. \"}")) @@ -243,14 +243,14 @@ void patchIsSysAdminOnlyAndAuditsRealChangesOnly() throws Exception { assertThat(auditCount("flavor.update", flavorId)).isEqualTo(2); // an empty body is a no-op request, not an edit → 422 - mockMvc.perform(patch("/api/v1/admin/vm-flavors/{id}", flavorId) + mockMvc.perform(patch("/api/v1/admin/vm-flavors/{id}", pub("vm_flavors", flavorId)) .header("Authorization", "Bearer " + sysAdminToken) .contentType(MediaType.APPLICATION_JSON) .content("{}")) .andExpect(status().isUnprocessableContent()) .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); - mockMvc.perform(patch("/api/v1/admin/vm-flavors/999999") + mockMvc.perform(patch("/api/v1/admin/vm-flavors/" + SeedFixtures.UNKNOWN_ID) .header("Authorization", "Bearer " + sysAdminToken) .contentType(MediaType.APPLICATION_JSON) .content("{\"vcpu\": 2}")) @@ -260,14 +260,14 @@ void patchIsSysAdminOnlyAndAuditsRealChangesOnly() throws Exception { // ── fixtures ─────────────────────────────────────────────────────────── - private static String byId(long id) { - return "$[?(@.id == %d)]".formatted(id); + private String byId(long id) { + return "$[?(@.id == '%s')]".formatted(pub("vm_flavors", id)); } private long auditCount(String action, long targetId) { return jdbcTemplate.queryForObject( "select count(*) from audit_logs where action = ? and target_id = ?", - Long.class, action, targetId); + Long.class, action, pub("vm_flavors", targetId).toString()); } private User ensureUser(String email, UserRole role) { @@ -279,4 +279,9 @@ private User ensureUser(String email, UserRole role) { return userRepository.save(user); }); } + + /** The public identifier of a row this test set up through direct SQL. */ + private UUID pub(String table, long id) { + return SeedFixtures.publicId(jdbcTemplate, table, id); + } } diff --git a/src/test/java/kr/ac/pusan/pickle/admin/AdminVmGatewayBlockTest.java b/src/test/java/kr/ac/pusan/pickle/admin/AdminVmGatewayBlockTest.java index a7f16d38..02877f8a 100644 --- a/src/test/java/kr/ac/pusan/pickle/admin/AdminVmGatewayBlockTest.java +++ b/src/test/java/kr/ac/pusan/pickle/admin/AdminVmGatewayBlockTest.java @@ -74,12 +74,12 @@ void setUp() { void blockAndUnblockPersistTheFlagAndRecordEventAndAuditOncePerTransition() throws Exception { long vmId = createVm("RUNNING"); - mockMvc.perform(patch("/api/v1/admin/vms/{id}/gateway-block", vmId) + mockMvc.perform(patch("/api/v1/admin/vms/{id}/gateway-block", pub("vms", vmId)) .header("Authorization", "Bearer " + sysAdminToken) .contentType(MediaType.APPLICATION_JSON) .content("{\"blocked\": true, \"reason\": \"비정상 트래픽 신고 확인\"}")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.id").value(vmId)) + .andExpect(jsonPath("$.id").value(pub("vms", vmId).toString())) .andExpect(jsonPath("$.sshGatewayBlocked").value(true)); assertThat(jdbcTemplate.queryForObject( @@ -91,7 +91,7 @@ void blockAndUnblockPersistTheFlagAndRecordEventAndAuditOncePerTransition() thro """, String.class, vmId)).contains("비정상 트래픽 신고 확인"); // idempotent re-application: 200, no new event/audit rows - mockMvc.perform(patch("/api/v1/admin/vms/{id}/gateway-block", vmId) + mockMvc.perform(patch("/api/v1/admin/vms/{id}/gateway-block", pub("vms", vmId)) .header("Authorization", "Bearer " + sysAdminToken) .contentType(MediaType.APPLICATION_JSON) .content("{\"blocked\": true}")) @@ -101,13 +101,13 @@ void blockAndUnblockPersistTheFlagAndRecordEventAndAuditOncePerTransition() thro assertThat(auditCount(vmId, "vm.gateway_block")).isEqualTo(1); // the admin list exposes the flag - mockMvc.perform(get("/api/v1/admin/vms?workspaceId=" + workspaceId) + mockMvc.perform(get("/api/v1/admin/vms?workspaceId=" + pub("workspaces", workspaceId)) .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.content[?(@.id == %d)].sshGatewayBlocked".formatted(vmId)) + .andExpect(jsonPath("$.content[?(@.id == '%s')].sshGatewayBlocked".formatted(pub("vms", vmId))) .value(true)); - mockMvc.perform(patch("/api/v1/admin/vms/{id}/gateway-block", vmId) + mockMvc.perform(patch("/api/v1/admin/vms/{id}/gateway-block", pub("vms", vmId)) .header("Authorization", "Bearer " + sysAdminToken) .contentType(MediaType.APPLICATION_JSON) .content("{\"blocked\": false}")) @@ -124,24 +124,24 @@ void onlySysAdminMayToggleAndUnknownVmAnswers404() throws Exception { long vmId = createVm("RUNNING"); String body = "{\"blocked\": true}"; - mockMvc.perform(patch("/api/v1/admin/vms/{id}/gateway-block", vmId) + mockMvc.perform(patch("/api/v1/admin/vms/{id}/gateway-block", pub("vms", vmId)) .header("Authorization", "Bearer " + orgAdminToken) .contentType(MediaType.APPLICATION_JSON).content(body)) .andExpect(status().isForbidden()); User sysManager = ensureUser("agb.sysmanager@pusan.ac.kr", UserRole.SYS_MANAGER, null); - mockMvc.perform(patch("/api/v1/admin/vms/{id}/gateway-block", vmId) + mockMvc.perform(patch("/api/v1/admin/vms/{id}/gateway-block", pub("vms", vmId)) .header("Authorization", "Bearer " + jwtService.createAccessToken(sysManager)) .contentType(MediaType.APPLICATION_JSON).content(body)) .andExpect(status().isForbidden()); User plainUser = ensureUser("agb.user@pusan.ac.kr", UserRole.USER, null); - mockMvc.perform(patch("/api/v1/admin/vms/{id}/gateway-block", vmId) + mockMvc.perform(patch("/api/v1/admin/vms/{id}/gateway-block", pub("vms", vmId)) .header("Authorization", "Bearer " + jwtService.createAccessToken(plainUser)) .contentType(MediaType.APPLICATION_JSON).content(body)) .andExpect(status().isForbidden()); - mockMvc.perform(patch("/api/v1/admin/vms/999999/gateway-block") + mockMvc.perform(patch("/api/v1/admin/vms/" + SeedFixtures.UNKNOWN_ID + "/gateway-block") .header("Authorization", "Bearer " + sysAdminToken) .contentType(MediaType.APPLICATION_JSON).content(body)) .andExpect(status().isNotFound()) @@ -154,7 +154,7 @@ void onlySysAdminMayToggleAndUnknownVmAnswers404() throws Exception { @Test void missingBlockedFieldAnswers422() throws Exception { long vmId = createVm("RUNNING"); - mockMvc.perform(patch("/api/v1/admin/vms/{id}/gateway-block", vmId) + mockMvc.perform(patch("/api/v1/admin/vms/{id}/gateway-block", pub("vms", vmId)) .header("Authorization", "Bearer " + sysAdminToken) .contentType(MediaType.APPLICATION_JSON) .content("{\"reason\": \"사유만 있는 요청\"}")) @@ -173,7 +173,7 @@ private long eventCount(long vmId, String type) { private long auditCount(long vmId, String action) { return jdbcTemplate.queryForObject( "select count(*) from audit_logs where action = ? and target_id = ?", - Long.class, action, vmId); + Long.class, action, pub("vms", vmId).toString()); } private User ensureUser(String email, UserRole role, Long userOrgId) { @@ -201,4 +201,9 @@ insert into vms (node_id, workspace_id, org_id, request_id, name, hostname, """, Long.class, nodeId, workspaceId, orgId, requestId, hostname, hostname, imageId, status); } + + /** The public identifier of a row this test set up through direct SQL. */ + private UUID pub(String table, long id) { + return SeedFixtures.publicId(jdbcTemplate, table, id); + } } diff --git a/src/test/java/kr/ac/pusan/pickle/admin/AdminVmInterventionTest.java b/src/test/java/kr/ac/pusan/pickle/admin/AdminVmInterventionTest.java index 10816477..403da0ec 100644 --- a/src/test/java/kr/ac/pusan/pickle/admin/AdminVmInterventionTest.java +++ b/src/test/java/kr/ac/pusan/pickle/admin/AdminVmInterventionTest.java @@ -90,39 +90,39 @@ insert into vm_events (vm_id, type, detail) values (?, 'CREATE', '생성') """, vmId); // all admin tiers read the detail; the viewer has no workspace role - mockMvc.perform(get("/api/v1/admin/vms/{id}", vmId) + mockMvc.perform(get("/api/v1/admin/vms/{id}", pub("vms", vmId)) .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.id").value(vmId)) + .andExpect(jsonPath("$.id").value(pub("vms", vmId).toString())) .andExpect(jsonPath("$.myResourceRole").isEmpty()) .andExpect(jsonPath("$.passwordRevealAllowed").value(false)); - mockMvc.perform(get("/api/v1/admin/vms/{id}", vmId) + mockMvc.perform(get("/api/v1/admin/vms/{id}", pub("vms", vmId)) .header("Authorization", "Bearer " + orgManagerToken)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.id").value(vmId)); + .andExpect(jsonPath("$.id").value(pub("vms", vmId).toString())); - mockMvc.perform(get("/api/v1/admin/vms/{id}/events", vmId) + mockMvc.perform(get("/api/v1/admin/vms/{id}/events", pub("vms", vmId)) .header("Authorization", "Bearer " + orgAdminToken)) .andExpect(status().isOk()) .andExpect(jsonPath("$.content[0].type").value("CREATE")); // cross-org admin: same 404 as an unknown id - Org otherOrg = orgRepository.findBySlug("avi-other").orElseGet(() -> - orgRepository.save(new Org("개입 테스트 타기관", "avi-other", null))); + Org otherOrg = orgRepository.findFirstByNameOrderByIdAsc("개입 테스트 타기관").orElseGet(() -> + orgRepository.save(new Org("개입 테스트 타기관", null))); String otherOrgAdminToken = jwtService.createAccessToken( ensureUser("avi.other.admin@pusan.ac.kr", UserRole.ORG_ADMIN, otherOrg.getId())); - mockMvc.perform(get("/api/v1/admin/vms/{id}", vmId) + mockMvc.perform(get("/api/v1/admin/vms/{id}", pub("vms", vmId)) .header("Authorization", "Bearer " + otherOrgAdminToken)) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND")); - mockMvc.perform(get("/api/v1/admin/vms/{id}/events", vmId) + mockMvc.perform(get("/api/v1/admin/vms/{id}/events", pub("vms", vmId)) .header("Authorization", "Bearer " + otherOrgAdminToken)) .andExpect(status().isNotFound()); // a plain user is refused by the role gate String userToken = jwtService.createAccessToken( ensureUser("avi.user@pusan.ac.kr", UserRole.USER, null)); - mockMvc.perform(get("/api/v1/admin/vms/{id}", vmId) + mockMvc.perform(get("/api/v1/admin/vms/{id}", pub("vms", vmId)) .header("Authorization", "Bearer " + userToken)) .andExpect(status().isForbidden()); } @@ -132,7 +132,7 @@ void adminPowerIntentsAreAcceptedAuditedAndSerializedByTheClaim() throws Excepti long vmId = createVm("STOPPED", null); // ORG_MANAGER may start an own-org VM (ratified scope) - mockMvc.perform(post("/api/v1/admin/vms/{id}/start", vmId) + mockMvc.perform(post("/api/v1/admin/vms/{id}/start", pub("vms", vmId)) .header("Authorization", "Bearer " + orgManagerToken)) .andExpect(status().isAccepted()); assertThat(jdbcTemplate.queryForObject( @@ -141,7 +141,7 @@ void adminPowerIntentsAreAcceptedAuditedAndSerializedByTheClaim() throws Excepti assertThat(auditCount(vmId, "vm.admin_start")).isEqualTo(1); // duplicate while the claim is in flight → 409, no extra audit row - mockMvc.perform(post("/api/v1/admin/vms/{id}/start", vmId) + mockMvc.perform(post("/api/v1/admin/vms/{id}/start", pub("vms", vmId)) .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isConflict()) .andExpect(jsonPath("$.code").value("VM_INVALID_STATE")); @@ -149,17 +149,17 @@ void adminPowerIntentsAreAcceptedAuditedAndSerializedByTheClaim() throws Excepti // wrong-state intent → 409 (RUNNING-only op on a STOPPED VM) long stopped = createVm("STOPPED", null); - mockMvc.perform(post("/api/v1/admin/vms/{id}/shutdown", stopped) + mockMvc.perform(post("/api/v1/admin/vms/{id}/shutdown", pub("vms", stopped)) .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isConflict()); // reboot + force-stop accept from RUNNING and audit long running = createVm("RUNNING", null); - mockMvc.perform(post("/api/v1/admin/vms/{id}/reboot", running) + mockMvc.perform(post("/api/v1/admin/vms/{id}/reboot", pub("vms", running)) .header("Authorization", "Bearer " + orgAdminToken)) .andExpect(status().isAccepted()); assertThat(auditCount(running, "vm.admin_reboot")).isEqualTo(1); - mockMvc.perform(post("/api/v1/admin/vms/{id}/force-stop", running) + mockMvc.perform(post("/api/v1/admin/vms/{id}/force-stop", pub("vms", running)) .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isAccepted()); assertThat(auditCount(running, "vm.admin_force_stop")).isEqualTo(1); @@ -172,7 +172,7 @@ void adminShutdownBypassesStopProtection() throws Exception { insert into vm_settings (vm_id, key, value) values (?, 'stop_protection', 'true'::jsonb) """, vmId); - mockMvc.perform(post("/api/v1/admin/vms/{id}/shutdown", vmId) + mockMvc.perform(post("/api/v1/admin/vms/{id}/shutdown", pub("vms", vmId)) .header("Authorization", "Bearer " + orgAdminToken)) .andExpect(status().isAccepted()); assertThat(auditCount(vmId, "vm.admin_shutdown")).isEqualTo(1); @@ -183,7 +183,7 @@ void expiredVmStillRefusesAdminStart() throws Exception { LocalDate today = LocalDate.now(KST); long vmId = createVm("STOPPED", today.minusDays(2)); - mockMvc.perform(post("/api/v1/admin/vms/{id}/start", vmId) + mockMvc.perform(post("/api/v1/admin/vms/{id}/start", pub("vms", vmId)) .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isConflict()) .andExpect(jsonPath("$.code").value("VM_EXPIRED")); @@ -192,13 +192,13 @@ void expiredVmStillRefusesAdminStart() throws Exception { @Test void crossOrgPowerIntentIsMaskedAs404() throws Exception { - Org otherOrg = orgRepository.findBySlug("avi-power-other").orElseGet(() -> - orgRepository.save(new Org("개입 전원 타기관", "avi-power-other", null))); + Org otherOrg = orgRepository.findFirstByNameOrderByIdAsc("개입 전원 타기관").orElseGet(() -> + orgRepository.save(new Org("개입 전원 타기관", null))); String otherOrgAdminToken = jwtService.createAccessToken( ensureUser("avi.pother.admin@pusan.ac.kr", UserRole.ORG_ADMIN, otherOrg.getId())); long vmId = createVm("STOPPED", null); - mockMvc.perform(post("/api/v1/admin/vms/{id}/start", vmId) + mockMvc.perform(post("/api/v1/admin/vms/{id}/start", pub("vms", vmId)) .header("Authorization", "Bearer " + otherOrgAdminToken)) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND")); @@ -211,7 +211,7 @@ void crossOrgPowerIntentIsMaskedAs404() throws Exception { private long auditCount(long vmId, String action) { return jdbcTemplate.queryForObject( "select count(*) from audit_logs where action = ? and target_id = ?", - Long.class, action, vmId); + Long.class, action, pub("vms", vmId).toString()); } private User ensureUser(String email, UserRole role, Long userOrgId) { @@ -239,4 +239,9 @@ insert into vms (node_id, workspace_id, org_id, request_id, name, hostname, """, Long.class, nodeId, workspaceId, orgId, requestId, hostname, hostname, imageId, status, endDate); } + + /** The public identifier of a row this test set up through direct SQL. */ + private UUID pub(String table, long id) { + return SeedFixtures.publicId(jdbcTemplate, table, id); + } } diff --git a/src/test/java/kr/ac/pusan/pickle/admin/AdminVmPeriodTest.java b/src/test/java/kr/ac/pusan/pickle/admin/AdminVmPeriodTest.java index b756badc..1f4fd4f8 100644 --- a/src/test/java/kr/ac/pusan/pickle/admin/AdminVmPeriodTest.java +++ b/src/test/java/kr/ac/pusan/pickle/admin/AdminVmPeriodTest.java @@ -97,7 +97,7 @@ void adminVmListFiltersByExpiryAndExposesTheNewFields() throws Exception { long deletedExpired = createVm(orgId, workspaceId, "DELETED", today.minusDays(3)); long undated = createVm(orgId, workspaceId, "RUNNING", null); - mockMvc.perform(get("/api/v1/admin/vms?expiringInDays=7&workspaceId=" + workspaceId) + mockMvc.perform(get("/api/v1/admin/vms?expiringInDays=7&workspaceId=" + pub("workspaces", workspaceId)) .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isOk()) .andExpect(jsonPath(byId(expiring)).exists()) @@ -107,7 +107,7 @@ void adminVmListFiltersByExpiryAndExposesTheNewFields() throws Exception { .andExpect(jsonPath(byId(expiring) + ".endDate") .value(today.plusDays(5).toString())); - mockMvc.perform(get("/api/v1/admin/vms?expired=true&workspaceId=" + workspaceId) + mockMvc.perform(get("/api/v1/admin/vms?expired=true&workspaceId=" + pub("workspaces", workspaceId)) .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isOk()) .andExpect(jsonPath(byId(expired)).exists()) @@ -116,7 +116,7 @@ void adminVmListFiltersByExpiryAndExposesTheNewFields() throws Exception { .andExpect(jsonPath(byId(undated)).doesNotExist()); // both filters AND to an empty page by design - mockMvc.perform(get("/api/v1/admin/vms?expiringInDays=7&expired=true&workspaceId=" + workspaceId) + mockMvc.perform(get("/api/v1/admin/vms?expiringInDays=7&expired=true&workspaceId=" + pub("workspaces", workspaceId)) .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isOk()) .andExpect(jsonPath("$.content").isEmpty()); @@ -136,18 +136,18 @@ void expiredVmRefusesStartUntilThePeriodIsExtended() throws Exception { jdbcTemplate.update("update vms set expiry_stopped_at = now()," + " last_expiry_notice_stage = 1 where id = ?", vmId); - mockMvc.perform(post("/api/v1/vms/{id}/start", vmId) + mockMvc.perform(post("/api/v1/vms/{id}/start", pub("vms", vmId)) .header("Authorization", "Bearer " + memberToken)) .andExpect(status().isConflict()) .andExpect(jsonPath("$.code").value("VM_EXPIRED")); // the admin extends the period → markers clear in the same update - mockMvc.perform(patch("/api/v1/admin/vms/{id}/period", vmId) + mockMvc.perform(patch("/api/v1/admin/vms/{id}/period", pub("vms", vmId)) .header("Authorization", "Bearer " + sysAdminToken) .contentType(MediaType.APPLICATION_JSON) .content("{\"endDate\": \"%s\"}".formatted(today.plusDays(30)))) .andExpect(status().isOk()) - .andExpect(jsonPath("$.id").value(vmId)) + .andExpect(jsonPath("$.id").value(pub("vms", vmId).toString())) .andExpect(jsonPath("$.endDate").value(today.plusDays(30).toString())) .andExpect(jsonPath("$.expiryStoppedAt").isEmpty()) .andExpect(jsonPath("$.updatedAt").isNotEmpty()); @@ -163,10 +163,10 @@ select count(*) from vm_events where vm_id = ? and type = 'PERIOD_UPDATE' assertThat(jdbcTemplate.queryForObject(""" select count(*) from audit_logs where action = 'vm.period_update' and target_id = ? - """, Long.class, vmId)).isEqualTo(1); + """, Long.class, pub("vms", vmId).toString())).isEqualTo(1); // guard lifted → start is accepted again - mockMvc.perform(post("/api/v1/vms/{id}/start", vmId) + mockMvc.perform(post("/api/v1/vms/{id}/start", pub("vms", vmId).toString()) .header("Authorization", "Bearer " + memberToken)) .andExpect(status().isAccepted()); } @@ -177,7 +177,7 @@ void periodPatchValidatesDatesAndDeletionState() throws Exception { jdbcTemplate.update("update vms set start_date = ? where id = ?", today.minusDays(30), vmId); // endDate in the past → 422 with the field error - mockMvc.perform(patch("/api/v1/admin/vms/{id}/period", vmId) + mockMvc.perform(patch("/api/v1/admin/vms/{id}/period", pub("vms", vmId)) .header("Authorization", "Bearer " + sysAdminToken) .contentType(MediaType.APPLICATION_JSON) .content("{\"endDate\": \"%s\"}".formatted(today.minusDays(1)))) @@ -186,7 +186,7 @@ void periodPatchValidatesDatesAndDeletionState() throws Exception { .andExpect(jsonPath("$.errors[0].field").value("endDate")); // endDate before startDate → 422 - mockMvc.perform(patch("/api/v1/admin/vms/{id}/period", vmId) + mockMvc.perform(patch("/api/v1/admin/vms/{id}/period", pub("vms", vmId)) .header("Authorization", "Bearer " + sysAdminToken) .contentType(MediaType.APPLICATION_JSON) .content("{\"endDate\": \"%s\", \"startDate\": \"%s\"}".formatted( @@ -196,7 +196,7 @@ void periodPatchValidatesDatesAndDeletionState() throws Exception { // deletion-bound VM → 409 VM_INVALID_STATE long scheduled = createVm(orgId, workspaceId, "RUNNING", today.plusDays(10)); jdbcTemplate.update("update vms set delete_scheduled_for = now() where id = ?", scheduled); - mockMvc.perform(patch("/api/v1/admin/vms/{id}/period", scheduled) + mockMvc.perform(patch("/api/v1/admin/vms/{id}/period", pub("vms", scheduled)) .header("Authorization", "Bearer " + sysAdminToken) .contentType(MediaType.APPLICATION_JSON) .content("{\"endDate\": \"%s\"}".formatted(today.plusDays(30)))) @@ -204,7 +204,7 @@ void periodPatchValidatesDatesAndDeletionState() throws Exception { .andExpect(jsonPath("$.code").value("VM_INVALID_STATE")); long deleting = createVm(orgId, workspaceId, "DELETING", today.plusDays(10)); - mockMvc.perform(patch("/api/v1/admin/vms/{id}/period", deleting) + mockMvc.perform(patch("/api/v1/admin/vms/{id}/period", pub("vms", deleting)) .header("Authorization", "Bearer " + sysAdminToken) .contentType(MediaType.APPLICATION_JSON) .content("{\"endDate\": \"%s\"}".formatted(today.plusDays(30)))) @@ -214,26 +214,26 @@ void periodPatchValidatesDatesAndDeletionState() throws Exception { @Test void orgAdminIsMaskedFromOtherOrgsVmsAndUsersAreForbidden() throws Exception { - Org otherOrg = orgRepository.findBySlug("avp-other").orElseGet(() -> - orgRepository.save(new Org("기간 테스트 타기관", "avp-other", null))); + Org otherOrg = orgRepository.findFirstByNameOrderByIdAsc("기간 테스트 타기관").orElseGet(() -> + orgRepository.save(new Org("기간 테스트 타기관", null))); User otherOrgAdmin = ensureUser("avp.other.admin@pusan.ac.kr", UserRole.ORG_ADMIN, otherOrg.getId()); String otherOrgAdminToken = jwtService.createAccessToken(otherOrgAdmin); long vmId = createVm(orgId, workspaceId, "RUNNING", today.plusDays(10)); String body = "{\"endDate\": \"%s\"}".formatted(today.plusDays(30)); - mockMvc.perform(patch("/api/v1/admin/vms/{id}/period", vmId) + mockMvc.perform(patch("/api/v1/admin/vms/{id}/period", pub("vms", vmId)) .header("Authorization", "Bearer " + otherOrgAdminToken) .contentType(MediaType.APPLICATION_JSON).content(body)) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND")); - mockMvc.perform(patch("/api/v1/admin/vms/999999/period") + mockMvc.perform(patch("/api/v1/admin/vms/" + SeedFixtures.UNKNOWN_ID + "/period") .header("Authorization", "Bearer " + sysAdminToken) .contentType(MediaType.APPLICATION_JSON).content(body)) .andExpect(status().isNotFound()); - mockMvc.perform(patch("/api/v1/admin/vms/{id}/period", vmId) + mockMvc.perform(patch("/api/v1/admin/vms/{id}/period", pub("vms", vmId)) .header("Authorization", "Bearer " + memberToken) .contentType(MediaType.APPLICATION_JSON).content(body)) .andExpect(status().isForbidden()); @@ -241,8 +241,8 @@ void orgAdminIsMaskedFromOtherOrgsVmsAndUsersAreForbidden() throws Exception { // ── fixtures ─────────────────────────────────────────────────────────── - private static String byId(long vmId) { - return "$.content[?(@.id == %d)]".formatted(vmId); + private String byId(long vmId) { + return "$.content[?(@.id == '%s')]".formatted(pub("vms", vmId)); } private User ensureUser(String email, UserRole role, Long userOrgId) { @@ -270,4 +270,9 @@ insert into vms (node_id, workspace_id, org_id, request_id, name, hostname, """, Long.class, nodeId, vmWorkspaceId, vmOrgId, requestId, hostname, hostname, imageId, status, endDate); } + + /** The public identifier of a row this test set up through direct SQL. */ + private UUID pub(String table, long id) { + return SeedFixtures.publicId(jdbcTemplate, table, id); + } } diff --git a/src/test/java/kr/ac/pusan/pickle/admin/AdminVmsTest.java b/src/test/java/kr/ac/pusan/pickle/admin/AdminVmsTest.java index 6092ec21..7057938d 100644 --- a/src/test/java/kr/ac/pusan/pickle/admin/AdminVmsTest.java +++ b/src/test/java/kr/ac/pusan/pickle/admin/AdminVmsTest.java @@ -69,9 +69,9 @@ class AdminVmsTest { @BeforeEach void setUp() { - org = orgRepository.findBySlug(SeedFixtures.ORG_SLUG).orElseThrow(); - otherOrg = orgRepository.findBySlug("advm-other").orElseGet(() -> - orgRepository.save(new Org("다른 기관", "advm-other", null))); + org = orgRepository.findFirstByNameOrderByIdAsc(SeedFixtures.ORG_NAME).orElseThrow(); + otherOrg = orgRepository.findFirstByNameOrderByIdAsc("VM 목록 타기관").orElseGet(() -> + orgRepository.save(new Org("VM 목록 타기관", null))); User regularUser = ensureUser("advm.user@pusan.ac.kr", "목록학생", UserRole.USER, null); User otherOrgAdmin = ensureUser("advm.other.admin@pusan.ac.kr", "타기관관리자", UserRole.ORG_ADMIN, otherOrg.getId()); @@ -102,13 +102,13 @@ void orgAdminIsPinnedToTheirOrgAndCrossOrgFilterAnswers404() throws Exception { .andExpect(jsonPath(byId(vmRunningA1)).exists()) .andExpect(jsonPath(byId(vmStoppedA2)).exists()) .andExpect(jsonPath(byId(vmRunningB)).doesNotExist()); - mockMvc.perform(get("/api/v1/admin/vms?orgId=" + org.getId()) + mockMvc.perform(get("/api/v1/admin/vms?orgId=" + org.getPublicId()) .header("Authorization", "Bearer " + orgAdminToken)) .andExpect(status().isOk()) .andExpect(jsonPath(byId(vmRunningA1)).exists()); // another org in the filter → 404, never 403 (existence stays private) - mockMvc.perform(get("/api/v1/admin/vms?orgId=" + otherOrg.getId()) + mockMvc.perform(get("/api/v1/admin/vms?orgId=" + otherOrg.getPublicId()) .header("Authorization", "Bearer " + orgAdminToken)) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND")); @@ -127,18 +127,18 @@ void sysAdminSeesAllOrgsAndMayFilter() throws Exception { .andExpect(jsonPath(byId(vmRunningA1)).exists()) .andExpect(jsonPath(byId(vmStoppedA2)).exists()) .andExpect(jsonPath(byId(vmRunningB)).exists()); - mockMvc.perform(get("/api/v1/admin/vms?orgId=" + otherOrg.getId()) + mockMvc.perform(get("/api/v1/admin/vms?orgId=" + otherOrg.getPublicId()) .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isOk()) .andExpect(jsonPath(byId(vmRunningB)).exists()) .andExpect(jsonPath(byId(vmRunningA1)).doesNotExist()); - mockMvc.perform(get("/api/v1/admin/vms?workspaceId=" + workspaceA2) + mockMvc.perform(get("/api/v1/admin/vms?workspaceId=" + pub("workspaces", workspaceA2)) .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isOk()) .andExpect(jsonPath(byId(vmStoppedA2)).exists()) .andExpect(jsonPath(byId(vmRunningA1)).doesNotExist()) .andExpect(jsonPath("$.content.length()").value(1)); - mockMvc.perform(get("/api/v1/admin/vms?orgId=%d&status=STOPPED".formatted(org.getId())) + mockMvc.perform(get("/api/v1/admin/vms?orgId=%s&status=STOPPED".formatted(org.getPublicId())) .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isOk()) .andExpect(jsonPath(byId(vmStoppedA2)).exists()) @@ -147,18 +147,18 @@ void sysAdminSeesAllOrgsAndMayFilter() throws Exception { @Test void returnsTheVmSummaryShapeAndPages() throws Exception { - mockMvc.perform(get("/api/v1/admin/vms?workspaceId=" + workspaceA1) + mockMvc.perform(get("/api/v1/admin/vms?workspaceId=" + pub("workspaces", workspaceA1)) .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.content[0].id").value(vmRunningA1)) + .andExpect(jsonPath("$.content[0].id").value(pub("vms", vmRunningA1).toString())) .andExpect(jsonPath("$.content[0].name").isNotEmpty()) .andExpect(jsonPath("$.content[0].hostname").isNotEmpty()) .andExpect(jsonPath("$.content[0].status").value("RUNNING")) .andExpect(jsonPath("$.content[0].vcpu").value(2)) .andExpect(jsonPath("$.content[0].memoryMb").value(2048)) .andExpect(jsonPath("$.content[0].diskGb").value(10)) - .andExpect(jsonPath("$.content[0].workspaceId").value(workspaceA1)) - .andExpect(jsonPath("$.content[0].requestId").isNumber()) + .andExpect(jsonPath("$.content[0].workspaceId").value(pub("workspaces", workspaceA1).toString())) + .andExpect(jsonPath("$.content[0].requestId").isNotEmpty()) .andExpect(jsonPath("$.content[0].createdAt").isNotEmpty()) .andExpect(jsonPath("$.page").value(0)) .andExpect(jsonPath("$.size").value(20)) @@ -166,7 +166,7 @@ void returnsTheVmSummaryShapeAndPages() throws Exception { .andExpect(jsonPath("$.totalPages").value(1)); // paging: size=1 over the two org-A workspaces → 2 pages, newest first - mockMvc.perform(get("/api/v1/admin/vms?orgId=%d&size=1".formatted(org.getId())) + mockMvc.perform(get("/api/v1/admin/vms?orgId=%s&size=1".formatted(org.getPublicId())) .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isOk()) .andExpect(jsonPath("$.content.length()").value(1)) @@ -194,7 +194,7 @@ void searchesByNamePartialMatchWithLikeEscaping() throws Exception { // hostname is searched too — alpha's custom name doesn't contain the // seeded hostname prefix, so this hit proves the hostname column - mockMvc.perform(get("/api/v1/admin/vms?q=advm-vm-&workspaceId=" + workspaceA1) + mockMvc.perform(get("/api/v1/admin/vms?q=advm-vm-&workspaceId=" + pub("workspaces", workspaceA1)) .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isOk()) .andExpect(jsonPath(byId(alpha)).exists()); @@ -215,20 +215,20 @@ void sortsByWhitelistAndRejectsUnknownKeys() throws Exception { mockMvc.perform(get("/api/v1/admin/vms?q=sortsearch&sort=name") .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.content[0].id").value(alpha)) - .andExpect(jsonPath("$.content[1].id").value(bravo)); + .andExpect(jsonPath("$.content[0].id").value(pub("vms", alpha).toString())) + .andExpect(jsonPath("$.content[1].id").value(pub("vms", bravo).toString())); mockMvc.perform(get("/api/v1/admin/vms?q=sortsearch&sort=-name") .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.content[0].id").value(bravo)) - .andExpect(jsonPath("$.content[1].id").value(alpha)); + .andExpect(jsonPath("$.content[0].id").value(pub("vms", bravo).toString())) + .andExpect(jsonPath("$.content[1].id").value(pub("vms", alpha).toString())); // default stays newest-first when sort is omitted mockMvc.perform(get("/api/v1/admin/vms?q=sortsearch") .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.content[0].id").value(bravo)); + .andExpect(jsonPath("$.content[0].id").value(pub("vms", bravo).toString())); // arbitrary property names never reach the ORM mockMvc.perform(get("/api/v1/admin/vms?sort=initialPassword") @@ -238,8 +238,8 @@ void sortsByWhitelistAndRejectsUnknownKeys() throws Exception { .andExpect(jsonPath("$.errors[0].field").value("sort")); } - private static String byId(long vmId) { - return "$.content[?(@.id == %d)]".formatted(vmId); + private String byId(long vmId) { + return "$.content[?(@.id == '%s')]".formatted(pub("vms", vmId)); } private long createWorkspace() { @@ -279,4 +279,9 @@ private User ensureUser(String email, String name, UserRole role, Long orgId) { return userRepository.save(user); }); } + + /** The public identifier of a row this test set up through direct SQL. */ + private UUID pub(String table, long id) { + return SeedFixtures.publicId(jdbcTemplate, table, id); + } } diff --git a/src/test/java/kr/ac/pusan/pickle/admin/AdminWorkspacesTest.java b/src/test/java/kr/ac/pusan/pickle/admin/AdminWorkspacesTest.java index 9adefe2d..7d951094 100644 --- a/src/test/java/kr/ac/pusan/pickle/admin/AdminWorkspacesTest.java +++ b/src/test/java/kr/ac/pusan/pickle/admin/AdminWorkspacesTest.java @@ -92,7 +92,7 @@ void optionListCarriesKindAndCreatedAt() throws Exception { @Test void detailListsEveryMemberWithAccountStatusAndVmCount() throws Exception { - mockMvc.perform(get("/api/v1/admin/workspaces/{id}", workspaceId) + mockMvc.perform(get("/api/v1/admin/workspaces/{id}", pub("workspaces", workspaceId)) .header("Authorization", "Bearer " + orgAdminToken)) .andExpect(status().isOk()) .andExpect(jsonPath("$.name").value(slug)) @@ -101,15 +101,16 @@ void detailListsEveryMemberWithAccountStatusAndVmCount() throws Exception { .andExpect(jsonPath("$.vmCount").value(0)) .andExpect(jsonPath("$.members.length()").value(2)) .andExpect(jsonPath("$.members[0].workspaceRole").value("OWNER")) - .andExpect(jsonPath("$.members[?(@.userId == %d)].workspaceRole".formatted(ownerId)) + .andExpect(jsonPath("$.members[?(@.userId == '%s')].workspaceRole" + .formatted(pub("users", ownerId))) .value("OWNER")) - .andExpect(jsonPath("$.members[?(@.userId == %d)].userStatus" - .formatted(disabledMemberId)).value("DISABLED")); + .andExpect(jsonPath("$.members[?(@.userId == '%s')].userStatus" + .formatted(pub("users", disabledMemberId))).value("DISABLED")); } @Test void unknownDeletedAndCrossOrgWorkspacesAnswerTheSame404() throws Exception { - mockMvc.perform(get("/api/v1/admin/workspaces/999999") + mockMvc.perform(get("/api/v1/admin/workspaces/" + SeedFixtures.UNKNOWN_ID) .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isNotFound()); @@ -119,7 +120,7 @@ void unknownDeletedAndCrossOrgWorkspacesAnswerTheSame404() throws Exception { insert into workspaces (kind, name, deleted_at, deleted_by) values ('TEAM', ?, now(), ?) returning id """, Long.class, deletedSlug, ownerId); - mockMvc.perform(get("/api/v1/admin/workspaces/{id}", deleted) + mockMvc.perform(get("/api/v1/admin/workspaces/{id}", pub("workspaces", deleted)) .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isNotFound()); @@ -128,18 +129,18 @@ insert into workspaces (kind, name, deleted_at, deleted_by) long unlinked = jdbcTemplate.queryForObject( "insert into workspaces (kind, name) values ('TEAM', ?) returning id", Long.class, foreignSlug); - mockMvc.perform(get("/api/v1/admin/workspaces/{id}", unlinked) + mockMvc.perform(get("/api/v1/admin/workspaces/{id}", pub("workspaces", unlinked)) .header("Authorization", "Bearer " + orgAdminToken)) .andExpect(status().isNotFound()); // ...but the sys tier still sees it - mockMvc.perform(get("/api/v1/admin/workspaces/{id}", unlinked) + mockMvc.perform(get("/api/v1/admin/workspaces/{id}", pub("workspaces", unlinked)) .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isOk()); // a plain user is refused by the role gate String userToken = jwtService.createAccessToken( ensureUser("agr.user." + slug + "@pusan.ac.kr", UserStatus.ACTIVE)); - mockMvc.perform(get("/api/v1/admin/workspaces/{id}", workspaceId) + mockMvc.perform(get("/api/v1/admin/workspaces/{id}", pub("workspaces", workspaceId)) .header("Authorization", "Bearer " + userToken)) .andExpect(status().isForbidden()); } @@ -159,4 +160,9 @@ private User ensureUser(String email, UserStatus status) { return userRepository.save(user); }); } + + /** The public identifier of a row this test set up through direct SQL. */ + private UUID pub(String table, long id) { + return SeedFixtures.publicId(jdbcTemplate, table, id); + } } 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 2e808edb..0e70a9a2 100644 --- a/src/test/java/kr/ac/pusan/pickle/admin/ApprovalTest.java +++ b/src/test/java/kr/ac/pusan/pickle/admin/ApprovalTest.java @@ -10,6 +10,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.UUID; import kr.ac.pusan.pickle.inventory.CatalogStatus; import kr.ac.pusan.pickle.inventory.VmFlavor; import kr.ac.pusan.pickle.inventory.VmFlavorRepository; @@ -93,9 +94,9 @@ class ApprovalTest { @BeforeEach void setUp() { - org = orgRepository.findBySlug(SeedFixtures.ORG_SLUG).orElseThrow(); - otherOrg = orgRepository.findBySlug("appr-other").orElseGet(() -> - orgRepository.save(new Org("다른 기관", "appr-other", null))); + org = orgRepository.findFirstByNameOrderByIdAsc(SeedFixtures.ORG_NAME).orElseThrow(); + otherOrg = orgRepository.findFirstByNameOrderByIdAsc("승인 타기관").orElseGet(() -> + orgRepository.save(new Org("승인 타기관", null))); regularUser = ensureUser("appr.user@pusan.ac.kr", "승인학생", UserRole.USER, null); User orgAdmin = userRepository.findByEmail(SeedFixtures.ORGADMIN_EMAIL).orElseThrow(); User otherOrgAdmin = ensureUser("appr.other.admin@pusan.ac.kr", "타기관관리자", @@ -118,7 +119,7 @@ void queueIsOrgScopedAndStatusFilterable() throws Exception { long workspaceId = createTeam(userToken, "appr-queue-x1"); long submitted = submit(userToken, workspaceId); long canceled = submit(userToken, workspaceId); - postJson("/api/v1/requests/" + canceled + "/cancel", userToken, Map.of()) + postJson("/api/v1/requests/" + pub("requests", canceled) + "/cancel", userToken, Map.of()) .andExpect(status().isOk()); // users have no admin queue → 403 ACCESS_DENIED @@ -129,51 +130,51 @@ void queueIsOrgScopedAndStatusFilterable() throws Exception { // own-org admin: no status → ALL statuses (v0.2.3); explicit SUBMITTED filters mockMvc.perform(get("/api/v1/admin/requests").header("Authorization", "Bearer " + orgAdminToken)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.content[?(@.id == %d)]".formatted(submitted)).exists()) - .andExpect(jsonPath("$.content[?(@.id == %d)]".formatted(canceled)).exists()); + .andExpect(jsonPath("$.content[?(@.id == '%s')]".formatted(pub("requests", submitted))).exists()) + .andExpect(jsonPath("$.content[?(@.id == '%s')]".formatted(pub("requests", canceled))).exists()); mockMvc.perform(get("/api/v1/admin/requests?status=SUBMITTED") .header("Authorization", "Bearer " + orgAdminToken)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.content[?(@.id == %d)]".formatted(submitted)).exists()) - .andExpect(jsonPath("$.content[?(@.id == %d)]".formatted(canceled)).doesNotExist()); + .andExpect(jsonPath("$.content[?(@.id == '%s')]".formatted(pub("requests", submitted))).exists()) + .andExpect(jsonPath("$.content[?(@.id == '%s')]".formatted(pub("requests", canceled))).doesNotExist()); // other-org admin sees an empty queue and 404s on the request itself mockMvc.perform(get("/api/v1/admin/requests") .header("Authorization", "Bearer " + otherOrgAdminToken)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.content[?(@.id == %d)]".formatted(submitted)).doesNotExist()); - mockMvc.perform(get("/api/v1/admin/requests/" + submitted) + .andExpect(jsonPath("$.content[?(@.id == '%s')]".formatted(pub("requests", submitted))).doesNotExist()); + mockMvc.perform(get("/api/v1/admin/requests/" + pub("requests", submitted)) .header("Authorization", "Bearer " + otherOrgAdminToken)) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND")); - mockMvc.perform(get("/api/v1/admin/requests/" + submitted + "/context") + mockMvc.perform(get("/api/v1/admin/requests/" + pub("requests", submitted) + "/context") .header("Authorization", "Bearer " + otherOrgAdminToken)) .andExpect(status().isNotFound()); - postJson("/api/v1/admin/requests/" + submitted + "/approve", otherOrgAdminToken, + postJson("/api/v1/admin/requests/" + pub("requests", submitted) + "/approve", otherOrgAdminToken, approveBody()) .andExpect(status().isNotFound()); - postJson("/api/v1/admin/requests/" + submitted + "/reject", otherOrgAdminToken, + postJson("/api/v1/admin/requests/" + pub("requests", submitted) + "/reject", otherOrgAdminToken, Map.of("comment", "타 기관 반려 시도")) .andExpect(status().isNotFound()); // ORG_ADMIN cannot escape their org via the orgId filter (pinned) - mockMvc.perform(get("/api/v1/admin/requests?orgId=" + otherOrg.getId()) + mockMvc.perform(get("/api/v1/admin/requests?orgId=" + otherOrg.getPublicId()) .header("Authorization", "Bearer " + orgAdminToken)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.content[?(@.id == %d)]".formatted(submitted)).exists()); + .andExpect(jsonPath("$.content[?(@.id == '%s')]".formatted(pub("requests", submitted))).exists()); // SYS_ADMIN sees all orgs and may filter by orgId mockMvc.perform(get("/api/v1/admin/requests").header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.content[?(@.id == %d)]".formatted(submitted)).exists()); - mockMvc.perform(get("/api/v1/admin/requests?orgId=" + otherOrg.getId()) + .andExpect(jsonPath("$.content[?(@.id == '%s')]".formatted(pub("requests", submitted))).exists()); + mockMvc.perform(get("/api/v1/admin/requests?orgId=" + otherOrg.getPublicId()) .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.content[?(@.id == %d)]".formatted(submitted)).doesNotExist()); - mockMvc.perform(get("/api/v1/admin/requests/" + submitted) + .andExpect(jsonPath("$.content[?(@.id == '%s')]".formatted(pub("requests", submitted))).doesNotExist()); + mockMvc.perform(get("/api/v1/admin/requests/" + pub("requests", submitted)) .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.id").value(submitted)); + .andExpect(jsonPath("$.id").value(pub("requests", submitted).toString())); } @Test @@ -182,23 +183,23 @@ void approveWritesReviewCreatesVmAndEnqueuesJob() throws Exception { long requestId = submit(userToken, workspaceId); // granted form validation: unusable image / disk below minimum / unknown node → 422 - postJson("/api/v1/admin/requests/" + requestId + "/approve", orgAdminToken, - with(approveBody(), "vm.grantedImageId", 999_999)) + postJson("/api/v1/admin/requests/" + pub("requests", requestId) + "/approve", orgAdminToken, + with(approveBody(), "vm.grantedImageId", SeedFixtures.UNKNOWN_ID)) .andExpect(status().isUnprocessableContent()) .andExpect(jsonPath("$.errors[0].field").value("vm.grantedImageId")); - postJson("/api/v1/admin/requests/" + requestId + "/approve", orgAdminToken, + postJson("/api/v1/admin/requests/" + pub("requests", requestId) + "/approve", orgAdminToken, with(approveBody(), "vm.grantedDiskGb", 5)) .andExpect(status().isUnprocessableContent()) .andExpect(jsonPath("$.errors[0].field").value("vm.grantedDiskGb")); - postJson("/api/v1/admin/requests/" + requestId + "/approve", orgAdminToken, - with(approveBody(), "vm.nodeId", 999_999)) + postJson("/api/v1/admin/requests/" + pub("requests", requestId) + "/approve", orgAdminToken, + with(approveBody(), "vm.nodeId", SeedFixtures.UNKNOWN_ID)) .andExpect(status().isUnprocessableContent()) .andExpect(jsonPath("$.errors[0].field").value("vm.nodeId")); long jobsBefore = jobRunrJobCount(); // approve → review embedded in the detail, request APPROVED - postJson("/api/v1/admin/requests/" + requestId + "/approve", orgAdminToken, approveBody()) + postJson("/api/v1/admin/requests/" + pub("requests", requestId) + "/approve", orgAdminToken, approveBody()) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value("APPROVED")) .andExpect(jsonPath("$.review.decision").value("APPROVE")) @@ -232,24 +233,24 @@ void approveWritesReviewCreatesVmAndEnqueuesJob() throws Exception { // approval is audit-logged Long audits = jdbcTemplate.queryForObject( "select count(*) from audit_logs where action = 'request.approve' and target_id = ?", - Long.class, requestId); + Long.class, pub("requests", requestId).toString()); assertThat(audits).isEqualTo(1); // the requester sees the decision in the user detail view - mockMvc.perform(get("/api/v1/requests/" + requestId) + mockMvc.perform(get("/api/v1/requests/" + pub("requests", requestId).toString()) .header("Authorization", "Bearer " + userToken)) .andExpect(status().isOk()) .andExpect(jsonPath("$.review.decision").value("APPROVE")); // double decisions → 409 REQUEST_ALREADY_DECIDED (approve/reject/cancel) - postJson("/api/v1/admin/requests/" + requestId + "/approve", orgAdminToken, approveBody()) + postJson("/api/v1/admin/requests/" + pub("requests", requestId) + "/approve", orgAdminToken, approveBody()) .andExpect(status().isConflict()) .andExpect(jsonPath("$.code").value("REQUEST_ALREADY_DECIDED")); - postJson("/api/v1/admin/requests/" + requestId + "/reject", orgAdminToken, + postJson("/api/v1/admin/requests/" + pub("requests", requestId) + "/reject", orgAdminToken, Map.of("comment", "이미 승인된 신청")) .andExpect(status().isConflict()) .andExpect(jsonPath("$.code").value("REQUEST_ALREADY_DECIDED")); - postJson("/api/v1/requests/" + requestId + "/cancel", userToken, Map.of()) + postJson("/api/v1/requests/" + pub("requests", requestId) + "/cancel", userToken, Map.of()) .andExpect(status().isConflict()) .andExpect(jsonPath("$.code").value("REQUEST_ALREADY_DECIDED")); @@ -268,8 +269,8 @@ void vmTakesItsGuestAccountFromTheGrantedImage() throws Exception { long workspaceId = createTeam(userToken, "appr-guest-account"); long requestId = submit(userToken, workspaceId); - postJson("/api/v1/admin/requests/" + requestId + "/approve", orgAdminToken, - with(approveBody(), "vm.grantedImageId", debian.getId())) + postJson("/api/v1/admin/requests/" + pub("requests", requestId) + "/approve", orgAdminToken, + with(approveBody(), "vm.grantedImageId", debian.getPublicId())) .andExpect(status().isOk()); Vm vm = vmRepository.findAll().stream() @@ -291,11 +292,11 @@ void grantedSlugFinalizesHostnameAndBlankFallsBackToAuto() throws Exception { long requestId = submit(userToken, workspaceId); // malformed (bean validation) / reserved word (shared list) → 422 grantedSlug - postJson("/api/v1/admin/requests/" + requestId + "/approve", orgAdminToken, + postJson("/api/v1/admin/requests/" + pub("requests", requestId) + "/approve", orgAdminToken, with(approveBody(), "vm.grantedSlug", "-bad-")) .andExpect(status().isUnprocessableContent()) .andExpect(jsonPath("$.errors[0].field").value("vm.grantedSlug")); - postJson("/api/v1/admin/requests/" + requestId + "/approve", orgAdminToken, + postJson("/api/v1/admin/requests/" + pub("requests", requestId) + "/approve", orgAdminToken, with(approveBody(), "vm.grantedSlug", "www")) .andExpect(status().isUnprocessableContent()) .andExpect(jsonPath("$.errors[0].field").value("vm.grantedSlug")); @@ -310,19 +311,19 @@ void grantedSlugFinalizesHostnameAndBlankFallsBackToAuto() throws Exception { jdbcTemplate.update( "update vms set deleted_at = now(), status = 'DELETED'::vm_status where id = ?", taken.getId()); - postJson("/api/v1/admin/requests/" + requestId + "/approve", orgAdminToken, + postJson("/api/v1/admin/requests/" + pub("requests", requestId) + "/approve", orgAdminToken, with(approveBody(), "vm.grantedSlug", "appr-slug-taken")) .andExpect(status().isUnprocessableContent()) .andExpect(jsonPath("$.errors[0].field").value("vm.grantedSlug")) .andExpect(jsonPath("$.errors[0].message").value( "이미 사용 중인 호스트명(슬러그)입니다. 다른 값을 입력하거나 비워서 자동 생성하세요.")); - mockMvc.perform(get("/api/v1/admin/requests/" + requestId) + mockMvc.perform(get("/api/v1/admin/requests/" + pub("requests", requestId)) .header("Authorization", "Bearer " + orgAdminToken)) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value("SUBMITTED")); // granted slug becomes vms.hostname verbatim - postJson("/api/v1/admin/requests/" + requestId + "/approve", orgAdminToken, + postJson("/api/v1/admin/requests/" + pub("requests", requestId) + "/approve", orgAdminToken, with(approveBody(), "vm.grantedSlug", "appr-slug-final")) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value("APPROVED")); @@ -332,7 +333,7 @@ void grantedSlugFinalizesHostnameAndBlankFallsBackToAuto() throws Exception { assertThat(named.getHostname()).isEqualTo("appr-slug-final"); // blank grantedSlug -> generated from the display name, or the workspace seed when there is none - postJson("/api/v1/admin/requests/" + otherRequestId + "/approve", orgAdminToken, + postJson("/api/v1/admin/requests/" + pub("requests", otherRequestId) + "/approve", orgAdminToken, with(approveBody(), "vm.grantedSlug", " ")) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value("APPROVED")); @@ -348,7 +349,7 @@ void asciiDisplayNameBecomesTheHostnameSeed() throws Exception { long workspaceId = createTeam(userToken, "appr-seed-x1"); long requestId = submit(userToken, workspaceId, org.getId(), "capstone api"); - postJson("/api/v1/admin/requests/" + requestId + "/approve", orgAdminToken, approveBody()) + postJson("/api/v1/admin/requests/" + pub("requests", requestId) + "/approve", orgAdminToken, approveBody()) .andExpect(status().isOk()); Vm vm = vmRepository.findAll().stream() @@ -368,7 +369,7 @@ void aDisplayNameTheSlugPolicyRefusesFallsBackInsteadOfFailing() throws Exceptio long workspaceId = createTeam(userToken, "appr-seed-x2"); long requestId = submit(userToken, workspaceId, org.getId(), "zzsex 프로젝트"); - postJson("/api/v1/admin/requests/" + requestId + "/approve", orgAdminToken, approveBody()) + postJson("/api/v1/admin/requests/" + pub("requests", requestId) + "/approve", orgAdminToken, approveBody()) .andExpect(status().isOk()); Vm vm = vmRepository.findAll().stream() @@ -382,7 +383,7 @@ void approveSeedsRequestedDisplayNameIntoVmSettings() throws Exception { long workspaceId = createTeam(userToken, "appr-dname-x1"); long requestId = submit(userToken, workspaceId, org.getId(), "학과 세미나 서버"); - postJson("/api/v1/admin/requests/" + requestId + "/approve", orgAdminToken, approveBody()) + postJson("/api/v1/admin/requests/" + pub("requests", requestId) + "/approve", orgAdminToken, approveBody()) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value("APPROVED")) .andExpect(jsonPath("$.displayName").value("학과 세미나 서버")); @@ -401,14 +402,14 @@ void approveSeedsRequestedDisplayNameIntoVmSettings() throws Exception { // carries the provenance instead. assertThat(jdbcTemplate.queryForObject(""" select count(*) from audit_logs where action = 'vm.setting_update' and target_id = ? - """, Long.class, vm.getId())).isEqualTo(0); + """, Long.class, vm.getPublicId().toString())).isEqualTo(0); assertThat(jdbcTemplate.queryForObject(""" select detail ->> 'displayName' from audit_logs where action = 'request.approve' and target_id = ? - """, String.class, requestId)).isEqualTo("학과 세미나 서버"); + """, String.class, pub("requests", requestId).toString().toString())).isEqualTo("학과 세미나 서버"); // the seeded name surfaces on the VM detail - mockMvc.perform(get("/api/v1/vms/" + vm.getId()) + mockMvc.perform(get("/api/v1/vms/" + vm.getPublicId()) .header("Authorization", "Bearer " + userToken)) .andExpect(status().isOk()) .andExpect(jsonPath("$.displayName").value("학과 세미나 서버")); @@ -427,7 +428,7 @@ void approveAuditsOnlyTheDisplayNameItActuallyStored() throws Exception { String zeroWidthOnly = new String(new int[] {0x200B, 0x200D, 0xFEFF}, 0, 3); long requestId = submit(userToken, workspaceId, org.getId(), zeroWidthOnly); - postJson("/api/v1/admin/requests/" + requestId + "/approve", orgAdminToken, approveBody()) + postJson("/api/v1/admin/requests/" + pub("requests", requestId) + "/approve", orgAdminToken, approveBody()) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value("APPROVED")); @@ -440,10 +441,10 @@ select count(*) from vm_settings where vm_id = ? and key = 'display_name' assertThat(jdbcTemplate.queryForObject(""" select detail ->> 'displayName' from audit_logs where action = 'request.approve' and target_id = ? - """, String.class, requestId)).isNull(); + """, String.class, pub("requests", requestId).toString())).isNull(); // and the VM simply has no display name - mockMvc.perform(get("/api/v1/vms/" + vm.getId()) + mockMvc.perform(get("/api/v1/vms/" + vm.getPublicId()) .header("Authorization", "Bearer " + userToken)) .andExpect(status().isOk()) .andExpect(jsonPath("$.displayName").value((Object) null)); @@ -462,18 +463,18 @@ insert into nodes (name, api_host, cpu_threads, memory_mb, vm_bridge, storage) """, Long.class, "appr-empty-" + java.util.UUID.randomUUID().toString().substring(0, 8)); // forcing that node → 422 (the pipeline would clone-fail there otherwise) - postJson("/api/v1/admin/requests/" + requestId + "/approve", orgAdminToken, - with(approveBody(), "vm.nodeId", emptyNodeId)) + postJson("/api/v1/admin/requests/" + pub("requests", requestId) + "/approve", orgAdminToken, + with(approveBody(), "vm.nodeId", pub("nodes", emptyNodeId))) .andExpect(status().isUnprocessableContent()) .andExpect(jsonPath("$.errors[0].field").value("vm.nodeId")); // the request is untouched — forcing the seeded pve1 (hosts the image) approves long pve1 = jdbcTemplate.queryForObject("select id from nodes where name = 'pve1'", Long.class); - postJson("/api/v1/admin/requests/" + requestId + "/approve", orgAdminToken, - with(approveBody(), "vm.nodeId", pve1)) + postJson("/api/v1/admin/requests/" + pub("requests", requestId) + "/approve", orgAdminToken, + with(approveBody(), "vm.nodeId", pub("nodes", pve1))) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value("APPROVED")) - .andExpect(jsonPath("$.vm.granted.nodeId").isNumber()); + .andExpect(jsonPath("$.vm.granted.nodeId").isNotEmpty()); // drop the extra ACTIVE node: capacity assertions elsewhere sum ACTIVE // nodes, and method order differs across environments @@ -486,14 +487,14 @@ void rejectRequiresCommentAndDecidesOnce() throws Exception { long requestId = submit(userToken, workspaceId); // comment is mandatory → 422 - postJson("/api/v1/admin/requests/" + requestId + "/reject", orgAdminToken, Map.of()) + postJson("/api/v1/admin/requests/" + pub("requests", requestId) + "/reject", orgAdminToken, Map.of()) .andExpect(status().isUnprocessableContent()) .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); - postJson("/api/v1/admin/requests/" + requestId + "/reject", orgAdminToken, + postJson("/api/v1/admin/requests/" + pub("requests", requestId) + "/reject", orgAdminToken, Map.of("comment", " ")) .andExpect(status().isUnprocessableContent()); - postJson("/api/v1/admin/requests/" + requestId + "/reject", orgAdminToken, + postJson("/api/v1/admin/requests/" + pub("requests", requestId) + "/reject", orgAdminToken, Map.of("comment", "기관 자원 여유 부족으로 반려합니다.")) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value("REJECTED")) @@ -507,9 +508,9 @@ void rejectRequiresCommentAndDecidesOnce() throws Exception { // rejection is audit-logged; second decision → 409 Long audits = jdbcTemplate.queryForObject( "select count(*) from audit_logs where action = 'request.reject' and target_id = ?", - Long.class, requestId); + Long.class, pub("requests", requestId).toString()); assertThat(audits).isEqualTo(1); - postJson("/api/v1/admin/requests/" + requestId + "/reject", orgAdminToken, + postJson("/api/v1/admin/requests/" + pub("requests", requestId).toString() + "/reject", orgAdminToken, Map.of("comment", "중복 반려")) .andExpect(status().isConflict()) .andExpect(jsonPath("$.code").value("REQUEST_ALREADY_DECIDED")); @@ -525,8 +526,8 @@ void approvalContextShowsPanelsHeadroomAndGuidance() throws Exception { // Dedicated org + applicant so counts/totals are isolated from the // other tests sharing this context's database. - Org ctxOrg = orgRepository.findBySlug("appr-ctx").orElseGet(() -> - orgRepository.save(new Org("컨텍스트 기관", "appr-ctx", null))); + Org ctxOrg = orgRepository.findFirstByNameOrderByIdAsc("컨텍스트 기관").orElseGet(() -> + orgRepository.save(new Org("컨텍스트 기관", null))); User ctxAdmin = ensureUser("appr.ctx.admin@pusan.ac.kr", "컨텍스트관리자", UserRole.ORG_ADMIN, ctxOrg.getId()); User ctxUser = ensureUser("appr.ctx.user@pusan.ac.kr", "컨텍스트학생", @@ -539,11 +540,11 @@ void approvalContextShowsPanelsHeadroomAndGuidance() throws Exception { // history material: one rejected, one approved (creates an active VM) long rejected = submit(ctxUserToken, workspaceId, ctxOrg.getId()); - postJson("/api/v1/admin/requests/" + rejected + "/reject", ctxAdminToken, + postJson("/api/v1/admin/requests/" + pub("requests", rejected) + "/reject", ctxAdminToken, Map.of("comment", "테스트 반려 사유")) .andExpect(status().isOk()); long approved = submit(ctxUserToken, workspaceId, ctxOrg.getId()); - postJson("/api/v1/admin/requests/" + approved + "/approve", ctxAdminToken, approveBody()) + postJson("/api/v1/admin/requests/" + pub("requests", approved) + "/approve", ctxAdminToken, approveBody()) .andExpect(status().isOk()); long current = submit(ctxUserToken, workspaceId, ctxOrg.getId()); @@ -553,10 +554,10 @@ void approvalContextShowsPanelsHeadroomAndGuidance() throws Exception { double expectedVcpuRatio = Math.round(2.0 / 40 * 100.0) / 100.0; double expectedMemoryRatio = Math.round(2048.0 / 79872 * 100.0) / 100.0; - mockMvc.perform(get("/api/v1/admin/requests/" + current + "/context") + mockMvc.perform(get("/api/v1/admin/requests/" + pub("requests", current) + "/context") .header("Authorization", "Bearer " + ctxAdminToken)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.applicant.id").value(ctxUser.getId())) + .andExpect(jsonPath("$.applicant.id").value(ctxUser.getPublicId().toString())) .andExpect(jsonPath("$.applicant.email").value(ctxUser.getEmail())) .andExpect(jsonPath("$.applicant.signupAt").isNotEmpty()) .andExpect(jsonPath("$.applicant.approvedCount").value(1)) @@ -564,21 +565,21 @@ void approvalContextShowsPanelsHeadroomAndGuidance() throws Exception { .andExpect(jsonPath("$.applicantResources.activeVms[?(@.vcpu == 2)]").exists()) .andExpect(jsonPath("$.applicantResources.totals.vcpu").value(2)) .andExpect(jsonPath("$.applicantResources.totals.memoryMb").value(2048)) - .andExpect(jsonPath("$.workspace.id").value(workspaceId)) + .andExpect(jsonPath("$.workspace.id").value(pub("workspaces", workspaceId).toString())) .andExpect(jsonPath("$.workspace.kind").value("TEAM")) .andExpect(jsonPath("$.workspace.members[?(@.role == 'OWNER')].name") .value(Matchers.hasItem("컨텍스트학생"))) .andExpect(jsonPath("$.workspace.members.length()").value(2)) .andExpect(jsonPath("$.workspace.totals.diskGb").value(20)) - .andExpect(jsonPath("$.history[?(@.requestId == %d)].decision".formatted(rejected)) + .andExpect(jsonPath("$.history[?(@.requestId == \'%s\')].decision".formatted(pub("requests", rejected))) .value(Matchers.hasItem("REJECT"))) - .andExpect(jsonPath("$.history[?(@.requestId == %d)].comment".formatted(rejected)) + .andExpect(jsonPath("$.history[?(@.requestId == \'%s\')].comment".formatted(pub("requests", rejected))) .value(Matchers.hasItem("테스트 반려 사유"))) - .andExpect(jsonPath("$.history[?(@.requestId == %d)].reviewerName".formatted(rejected)) + .andExpect(jsonPath("$.history[?(@.requestId == \'%s\')].reviewerName".formatted(pub("requests", rejected))) .value(Matchers.hasItem("컨텍스트관리자"))) - .andExpect(jsonPath("$.history[?(@.requestId == %d)].decision".formatted(approved)) + .andExpect(jsonPath("$.history[?(@.requestId == \'%s\')].decision".formatted(pub("requests", approved))) .value(Matchers.hasItem("APPROVE"))) - .andExpect(jsonPath("$.history[?(@.requestId == %d)]".formatted(current)).doesNotExist()) + .andExpect(jsonPath("$.history[?(@.requestId == \'%s\')]".formatted(pub("requests", current))).doesNotExist()) .andExpect(jsonPath("$.orgHeadroom.allocated.vcpu").value(2)) .andExpect(jsonPath("$.orgHeadroom.allocated.memoryMb").value(2048)) .andExpect(jsonPath("$.orgHeadroom.allocated.diskGb").value(20)) @@ -592,14 +593,14 @@ void approvalContextShowsPanelsHeadroomAndGuidance() throws Exception { // lowering the settings thresholds flips warnings + guidance try { jdbcTemplate.update("update settings set value = '0.01'::jsonb where key = 'memory_usage_warn'"); - mockMvc.perform(get("/api/v1/admin/requests/" + current + "/context") + mockMvc.perform(get("/api/v1/admin/requests/" + pub("requests", current) + "/context") .header("Authorization", "Bearer " + ctxAdminToken)) .andExpect(status().isOk()) .andExpect(jsonPath("$.orgHeadroom.warnings.length()").value(1)) .andExpect(jsonPath("$.guidance").value(ApprovalContextService.GUIDANCE_MEMORY)); jdbcTemplate.update("update settings set value = '0.01'::jsonb where key = 'vcpu_overcommit_warn'"); - mockMvc.perform(get("/api/v1/admin/requests/" + current + "/context") + mockMvc.perform(get("/api/v1/admin/requests/" + pub("requests", current) + "/context") .header("Authorization", "Bearer " + ctxAdminToken)) .andExpect(status().isOk()) .andExpect(jsonPath("$.orgHeadroom.warnings.length()").value(2)) @@ -625,13 +626,13 @@ void approvalRefusesARequestWhoseWorkspaceIsGone() throws Exception { jdbcTemplate.update("update workspaces set deleted_at = now(), deleted_by = ? where id = ?", regularUser.getId(), workspaceId); - postJson("/api/v1/admin/requests/" + requestId + "/approve", orgAdminToken, approveBody()) + postJson("/api/v1/admin/requests/" + pub("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, + postJson("/api/v1/admin/requests/" + pub("requests", survivor) + "/reject", orgAdminToken, Map.of("comment", "워크스페이스가 삭제되어 반려합니다.")) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value("REJECTED")); @@ -642,7 +643,7 @@ private Map approveBody() { vm.put("grantedVcpu", 2); vm.put("grantedMemoryMb", 2048); vm.put("grantedDiskGb", 20); - vm.put("grantedImageId", image.getId()); + vm.put("grantedImageId", image.getPublicId()); Map body = new HashMap<>(); body.put("comment", "요청 사양 그대로 승인합니다."); body.put("vm", vm); @@ -680,8 +681,8 @@ private long submit(String token, long workspaceId, long orgId) throws Exception private long submit(String token, long workspaceId, long orgId, String displayName) throws Exception { Map vm = new HashMap<>(); - vm.put("imageId", image.getId()); - vm.put("flavorId", flavor.getId()); + vm.put("imageId", image.getPublicId()); + vm.put("flavorId", flavor.getPublicId()); vm.put("reqVcpu", flavor.getVcpu()); vm.put("reqMemoryMb", flavor.getMemoryMb()); vm.put("reqDiskGb", flavor.getDiskGb()); @@ -690,14 +691,14 @@ private long submit(String token, long workspaceId, long orgId, String displayNa body.put("displayName", displayName); } body.put("type", "VM"); - body.put("workspaceId", workspaceId); - body.put("orgId", orgId); + body.put("workspaceId", pub("workspaces", workspaceId)); + body.put("orgId", pub("orgs", orgId)); body.put("purpose", "승인 흐름 테스트"); body.put("vm", vm); String response = postJson("/api/v1/requests", token, body) .andExpect(status().isCreated()) .andReturn().getResponse().getContentAsString(); - return objectMapper.readTree(response).get("id").asLong(); + return SeedFixtures.internalId(jdbcTemplate, "requests", UUID.fromString(objectMapper.readTree(response).get("id").asString())); } private long createTeam(String token, String slug) throws Exception { @@ -705,7 +706,7 @@ private long createTeam(String token, String slug) throws Exception { Map.of("kind", "TEAM", "name", "테스트 워크스페이스 " + slug)) .andExpect(status().isCreated()) .andReturn().getResponse().getContentAsString(); - return objectMapper.readTree(body).get("id").asLong(); + return SeedFixtures.internalId(jdbcTemplate, "workspaces", UUID.fromString(objectMapper.readTree(body).get("id").asString())); } /** Sudo-mode gate: mint the caller's X-Reauth-Token for the protected call. */ @@ -714,7 +715,7 @@ private String reauth(String token) { } private void addMember(String token, long workspaceId, String email, String role) throws Exception { - mockMvc.perform(post("/api/v1/workspaces/" + workspaceId + "/members") + mockMvc.perform(post("/api/v1/workspaces/" + pub("workspaces", workspaceId) + "/members") .header("Authorization", "Bearer " + token) .header(ReauthTestSupport.HEADER, reauth(token)) .contentType(MediaType.APPLICATION_JSON) @@ -740,4 +741,9 @@ private User ensureUser(String email, String name, UserRole role, Long orgId) { return userRepository.save(user); }); } + + /** The public identifier of a row this test set up through direct SQL. */ + private UUID pub(String table, long id) { + return SeedFixtures.publicId(jdbcTemplate, table, id); + } } diff --git a/src/test/java/kr/ac/pusan/pickle/admin/CapacityTrendTest.java b/src/test/java/kr/ac/pusan/pickle/admin/CapacityTrendTest.java index a6d65a6b..3409cb6f 100644 --- a/src/test/java/kr/ac/pusan/pickle/admin/CapacityTrendTest.java +++ b/src/test/java/kr/ac/pusan/pickle/admin/CapacityTrendTest.java @@ -71,7 +71,7 @@ class CapacityTrendTest { @BeforeEach void setUp() { String slug = "cap-" + UUID.randomUUID().toString().substring(0, 8); - org = orgRepository.save(new Org("추이 테스트 기관 " + slug, slug, null)); + org = orgRepository.save(new Org("추이 테스트 기관 " + slug, null)); User orgAdmin = createUser("cap.admin." + slug + "@pusan.ac.kr", UserRole.ORG_ADMIN, org.getId()); User regularUser = createUser("cap.user." + slug + "@pusan.ac.kr", UserRole.USER, null); @@ -98,7 +98,7 @@ void everyDayCountsTheVmsThatWereAliveWhenItEnded() throws Exception { createVm(SeedFixtures.seedOrgId(jdbcTemplate), midnight(today.minusDays(10)), null, "RUNNING"); - mockMvc.perform(get("/api/v1/admin/capacity-trend?days=7&orgId=" + org.getId()) + mockMvc.perform(get("/api/v1/admin/capacity-trend?days=7&orgId=" + org.getPublicId()) .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isOk()) .andExpect(jsonPath("$.from").value(today.minusDays(6).toString())) @@ -146,7 +146,7 @@ void scopingFollowsTheSummaryPanelItSitsBeside() throws Exception { .andExpect(status().isForbidden()); // An org-tier caller is pinned to their own org; another org is masked. - mockMvc.perform(get("/api/v1/admin/capacity-trend?orgId=" + otherOrgId) + mockMvc.perform(get("/api/v1/admin/capacity-trend?orgId=" + pub("orgs", otherOrgId)) .header("Authorization", "Bearer " + orgAdminToken)) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND")); @@ -199,4 +199,9 @@ insert into vms (node_id, workspace_id, org_id, request_id, name, hostname, java.sql.Timestamp.from(createdAt), deletedAt == null ? null : java.sql.Timestamp.from(deletedAt)); } + + /** The public identifier of a row this test set up through direct SQL. */ + private UUID pub(String table, long id) { + return SeedFixtures.publicId(jdbcTemplate, table, id); + } } diff --git a/src/test/java/kr/ac/pusan/pickle/announcement/AnnouncementTest.java b/src/test/java/kr/ac/pusan/pickle/announcement/AnnouncementTest.java index 45d5895c..8e8feb5c 100644 --- a/src/test/java/kr/ac/pusan/pickle/announcement/AnnouncementTest.java +++ b/src/test/java/kr/ac/pusan/pickle/announcement/AnnouncementTest.java @@ -85,9 +85,9 @@ class AnnouncementTest { @BeforeEach void setUp() { - org = orgRepository.findBySlug(SeedFixtures.ORG_SLUG).orElseThrow(); - otherOrg = orgRepository.findBySlug("ann-other").orElseGet(() -> - orgRepository.save(new Org("공지 타기관", "ann-other", null))); + org = orgRepository.findFirstByNameOrderByIdAsc(SeedFixtures.ORG_NAME).orElseThrow(); + otherOrg = orgRepository.findFirstByNameOrderByIdAsc("공지 타기관").orElseGet(() -> + orgRepository.save(new Org("공지 타기관", null))); ownMember = ensureRegularUser("ann.own.member@pusan.ac.kr", "공지자기관원", UserStatus.ACTIVE); inactiveMember = ensureRegularUser("ann.pending@pusan.ac.kr", "공지비활성", UserStatus.DISABLED); @@ -128,23 +128,23 @@ void scopeRulesGateAllOrgAndWorkspaceSends() throws Exception { .andExpect(jsonPath("$.code").value("ACCESS_DENIED")); // scope/target cross-field mismatches → 422 create(sysAdminToken, Map.of("title", "t", "body", "b", "scope", "ALL", - "workspaceId", mixedWorkspaceId)).andExpect(status().isUnprocessableContent()); + "workspaceId", pub("workspaces", mixedWorkspaceId))).andExpect(status().isUnprocessableContent()); create(orgAdminToken, Map.of("title", "t", "body", "b", "scope", "ORG", - "workspaceId", mixedWorkspaceId)).andExpect(status().isUnprocessableContent()); + "workspaceId", pub("workspaces", mixedWorkspaceId))).andExpect(status().isUnprocessableContent()); create(orgAdminToken, Map.of("title", "t", "body", "b", "scope", "WORKSPACE")) .andExpect(status().isUnprocessableContent()); create(orgAdminToken, Map.of("title", "t", "body", "b", "scope", "WORKSPACE", - "workspaceId", mixedWorkspaceId, "orgId", org.getId())) + "workspaceId", pub("workspaces", mixedWorkspaceId), "orgId", org.getPublicId())) .andExpect(status().isUnprocessableContent()); // ORG pinned: another org's id → 422 create(orgAdminToken, Map.of("title", "t", "body", "b", "scope", "ORG", - "orgId", otherOrg.getId())).andExpect(status().isUnprocessableContent()); + "orgId", otherOrg.getPublicId())).andExpect(status().isUnprocessableContent()); // WORKSPACE without resources in the caller's org → 404 (existence // masked), exactly like a workspace that does not exist at all create(orgAdminToken, Map.of("title", "t", "body", "b", "scope", "WORKSPACE", - "workspaceId", foreignWorkspaceId)).andExpect(status().isNotFound()); + "workspaceId", pub("workspaces", foreignWorkspaceId))).andExpect(status().isNotFound()); create(orgAdminToken, Map.of("title", "t", "body", "b", "scope", "WORKSPACE", - "workspaceId", 999999)).andExpect(status().isNotFound()); + "workspaceId", SeedFixtures.UNKNOWN_ID)).andExpect(status().isNotFound()); } @Test @@ -169,7 +169,7 @@ select count(*) from notifications ResultActions orgSend = create(orgAdminToken, Map.of( "title", "기관 공지", "body", "본문", "scope", "ORG")) .andExpect(status().isCreated()) - .andExpect(jsonPath("$.orgId").value(org.getId())); + .andExpect(jsonPath("$.orgId").value(org.getPublicId().toString())); long orgAnnId = createdId(orgSend); assertThat(recipientOf(orgAnnId, ownMember.getId())).isEqualTo(1); assertThat(recipientOf(orgAnnId, crossMember.getId())).isEqualTo(1); @@ -186,7 +186,7 @@ select count(*) from notifications // WORKSPACE by ORG_ADMIN: the gated workspace's ACTIVE members — all of them, // regardless of their own (non-)org long workspaceAnnId = createdId(create(orgAdminToken, Map.of( - "title", "워크스페이스 공지", "body", "본문", "scope", "WORKSPACE", "workspaceId", mixedWorkspaceId)) + "title", "워크스페이스 공지", "body", "본문", "scope", "WORKSPACE", "workspaceId", pub("workspaces", mixedWorkspaceId))) .andExpect(status().isCreated()) .andExpect(jsonPath("$.recipientCount").value(2))); assertThat(recipientOf(workspaceAnnId, ownMember.getId())).isEqualTo(1); @@ -196,7 +196,7 @@ select count(*) from notifications // WORKSPACE by SYS_ADMIN: same member set (no gate) createdId(create(sysAdminToken, Map.of( "title", "워크스페이스 공지(시스템)", "body", "본문", "scope", "WORKSPACE", - "workspaceId", foreignWorkspaceId)) + "workspaceId", pub("workspaces", foreignWorkspaceId))) .andExpect(status().isCreated()) .andExpect(jsonPath("$.recipientCount").value(1))); @@ -204,7 +204,7 @@ select count(*) from notifications assertThat(jdbcTemplate.queryForObject(""" select count(*) from audit_logs where action = 'announcement.create' and target_id = ? - """, Long.class, allId)).isEqualTo(1); + """, Long.class, pub("announcements", allId).toString())).isEqualTo(1); } @Test @@ -223,14 +223,14 @@ void listVisibilityFollowsTheSenderOrg() throws Exception { mockMvc.perform(get("/api/v1/admin/announcements?size=100") .header("Authorization", "Bearer " + orgAdminToken)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.content[?(@.id==" + allId + ")]").exists()) - .andExpect(jsonPath("$.content[?(@.id==" + ownOrgId + ")]").exists()) - .andExpect(jsonPath("$.content[?(@.id==" + otherOrgAnnId + ")]").doesNotExist()); + .andExpect(jsonPath("$.content[?(@.id==\'" + pub("announcements", allId) + "\')]").exists()) + .andExpect(jsonPath("$.content[?(@.id==\'" + pub("announcements", ownOrgId) + "\')]").exists()) + .andExpect(jsonPath("$.content[?(@.id==\'" + pub("announcements", otherOrgAnnId) + "\')]").doesNotExist()); // SYS_ADMIN: everything mockMvc.perform(get("/api/v1/admin/announcements?size=100") .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.content[?(@.id==" + otherOrgAnnId + ")]").exists()); + .andExpect(jsonPath("$.content[?(@.id==\'" + pub("announcements", otherOrgAnnId) + "\')]").exists()); // users → 403 mockMvc.perform(get("/api/v1/admin/announcements") .header("Authorization", "Bearer " + userToken)) @@ -254,7 +254,7 @@ void perAuthorHourlyBudgetCountsOnlyAcceptedSends() throws Exception { create(bursterToken, Map.of("title", "t", "body", "b", "scope", "WORKSPACE")) .andExpect(status().isUnprocessableContent()); create(bursterToken, Map.of("title", "t", "body", "b", "scope", "WORKSPACE", - "workspaceId", foreignWorkspaceId)).andExpect(status().isNotFound()); + "workspaceId", pub("workspaces", foreignWorkspaceId))).andExpect(status().isNotFound()); for (int i = 1; i <= 10; i++) { create(bursterToken, Map.of("title", "공지 " + i, "body", "b", "scope", "ORG")) .andExpect(status().isCreated()); @@ -272,26 +272,26 @@ void adminWorkspacePickerFollowsTheWorkspaceGate() throws Exception { mockMvc.perform(get("/api/v1/admin/workspaces") .header("Authorization", "Bearer " + orgAdminToken)) .andExpect(status().isOk()) - .andExpect(jsonPath("$[?(@.id==" + mixedWorkspaceId + " && @.memberCount==2)]") + .andExpect(jsonPath("$[?(@.id==\'" + pub("workspaces", mixedWorkspaceId) + "\' && @.memberCount==2)]") .exists()) - .andExpect(jsonPath("$[?(@.id==" + foreignWorkspaceId + ")]").doesNotExist()); + .andExpect(jsonPath("$[?(@.id==\'" + pub("workspaces", foreignWorkspaceId) + "\')]").doesNotExist()); // cross-org filter → 404 (existence stays private) - mockMvc.perform(get("/api/v1/admin/workspaces?orgId=" + otherOrg.getId()) + mockMvc.perform(get("/api/v1/admin/workspaces?orgId=" + otherOrg.getPublicId()) .header("Authorization", "Bearer " + orgAdminToken)) .andExpect(status().isNotFound()); // SYS_ADMIN: all workspaces without a filter; the org filter applies the gate mockMvc.perform(get("/api/v1/admin/workspaces") .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isOk()) - .andExpect(jsonPath("$[?(@.id==" + mixedWorkspaceId + " && @.memberCount==2)]") + .andExpect(jsonPath("$[?(@.id==\'" + pub("workspaces", mixedWorkspaceId) + "\' && @.memberCount==2)]") .exists()) - .andExpect(jsonPath("$[?(@.id==" + foreignWorkspaceId + " && @.memberCount==1)]") + .andExpect(jsonPath("$[?(@.id==\'" + pub("workspaces", foreignWorkspaceId) + "\' && @.memberCount==1)]") .exists()); - mockMvc.perform(get("/api/v1/admin/workspaces?orgId=" + otherOrg.getId()) + mockMvc.perform(get("/api/v1/admin/workspaces?orgId=" + otherOrg.getPublicId()) .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isOk()) - .andExpect(jsonPath("$[?(@.id==" + foreignWorkspaceId + ")]").exists()) - .andExpect(jsonPath("$[?(@.id==" + mixedWorkspaceId + ")]").doesNotExist()); + .andExpect(jsonPath("$[?(@.id==\'" + pub("workspaces", foreignWorkspaceId) + "\')]").exists()) + .andExpect(jsonPath("$[?(@.id==\'" + pub("workspaces", mixedWorkspaceId) + "\')]").doesNotExist()); // users → 403 mockMvc.perform(get("/api/v1/admin/workspaces") .header("Authorization", "Bearer " + userToken)) @@ -308,8 +308,10 @@ private ResultActions create(String token, Map body) throws Exce } private long createdId(ResultActions result) throws Exception { - return objectMapper.readTree(result.andReturn().getResponse().getContentAsString()) - .get("id").asLong(); + return SeedFixtures.internalId(jdbcTemplate, "announcements", + UUID.fromString(objectMapper + .readTree(result.andReturn().getResponse().getContentAsString()) + .get("id").asString())); } private long recipientRows(long announcementId) { @@ -350,4 +352,9 @@ private User ensureRegularUser(String email, String name, UserStatus status) { user.setStatus(status); return userRepository.save(user); } + + /** The public identifier of a row this test set up through direct SQL. */ + private UUID pub(String table, long id) { + return SeedFixtures.publicId(jdbcTemplate, table, id); + } } diff --git a/src/test/java/kr/ac/pusan/pickle/audit/AuditAfterCommitTest.java b/src/test/java/kr/ac/pusan/pickle/audit/AuditAfterCommitTest.java index b9ce7748..7436eb0f 100644 --- a/src/test/java/kr/ac/pusan/pickle/audit/AuditAfterCommitTest.java +++ b/src/test/java/kr/ac/pusan/pickle/audit/AuditAfterCommitTest.java @@ -40,7 +40,7 @@ class AuditAfterCommitTest { void recordsOnceWhenTheBusinessTransactionCommits() { String action = uniqueAction(); new TransactionTemplate(txManager).executeWithoutResult(status -> - auditService.recordAfterCommit(1L, "SYS_ADMIN", action, "test", 1L, + auditService.recordAfterCommit(1L, "SYS_ADMIN", action, "test", TARGET, Map.of("k", "v"), "127.0.0.1")); assertThat(countByAction(action)).isEqualTo(1); } @@ -49,7 +49,7 @@ void recordsOnceWhenTheBusinessTransactionCommits() { void recordsNothingWhenTheBusinessTransactionRollsBack() { String action = uniqueAction(); new TransactionTemplate(txManager).executeWithoutResult(status -> { - auditService.recordAfterCommit(1L, "SYS_ADMIN", action, "test", 1L, + auditService.recordAfterCommit(1L, "SYS_ADMIN", action, "test", TARGET, Map.of("k", "v"), "127.0.0.1"); status.setRollbackOnly(); }); @@ -59,11 +59,14 @@ void recordsNothingWhenTheBusinessTransactionRollsBack() { @Test void recordsImmediatelyWithNoActiveTransaction() { String action = uniqueAction(); - auditService.recordAfterCommit(1L, "SYS_ADMIN", action, "test", 1L, + auditService.recordAfterCommit(1L, "SYS_ADMIN", action, "test", TARGET, Map.of("k", "v"), "127.0.0.1"); assertThat(countByAction(action)).isEqualTo(1); } + /** Any well-formed public id: this test is about when the row is written. */ + private static final UUID TARGET = UUID.fromString("00000000-0000-4000-8000-00000000a001"); + private static String uniqueAction() { return "test.after_commit." + UUID.randomUUID(); } diff --git a/src/test/java/kr/ac/pusan/pickle/audit/AuditReadApiTest.java b/src/test/java/kr/ac/pusan/pickle/audit/AuditReadApiTest.java index c35431e1..81d06023 100644 --- a/src/test/java/kr/ac/pusan/pickle/audit/AuditReadApiTest.java +++ b/src/test/java/kr/ac/pusan/pickle/audit/AuditReadApiTest.java @@ -68,9 +68,9 @@ class AuditReadApiTest { @BeforeEach void setUp() { - org = orgRepository.findBySlug(SeedFixtures.ORG_SLUG).orElseThrow(); - otherOrg = orgRepository.findBySlug("aud-other").orElseGet(() -> - orgRepository.save(new Org("감사 타기관", "aud-other", null))); + org = orgRepository.findFirstByNameOrderByIdAsc(SeedFixtures.ORG_NAME).orElseThrow(); + otherOrg = orgRepository.findFirstByNameOrderByIdAsc("감사 타기관").orElseGet(() -> + orgRepository.save(new Org("감사 타기관", null))); self = ensureRegularUser("aud.self@pusan.ac.kr", "감사본인"); peer = ensureRegularUser("aud.peer@pusan.ac.kr", "감사동료"); otherOrgUser = ensureRegularUser("aud.other@pusan.ac.kr", "감사타인"); @@ -155,10 +155,10 @@ void adminAuditScopesOrgAdminByDerivedMembershipInSql() throws Exception { .andExpect(jsonPath("$.content[0].actorId").value((Object) null)) .andExpect(jsonPath("$.content[0].actorEmail").value((Object) null)); // cross-org orgId → 404 mask; SYS_ADMIN may filter by org - mockMvc.perform(get("/api/v1/admin/audit?orgId=" + otherOrg.getId()) + mockMvc.perform(get("/api/v1/admin/audit?orgId=" + otherOrg.getPublicId()) .header("Authorization", "Bearer " + orgAdminToken)) .andExpect(status().isNotFound()); - mockMvc.perform(get("/api/v1/admin/audit" + actionFilter + "&orgId=" + otherOrg.getId()) + mockMvc.perform(get("/api/v1/admin/audit" + actionFilter + "&orgId=" + otherOrg.getPublicId()) .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isOk()) .andExpect(jsonPath("$.totalElements").value(1)) diff --git a/src/test/java/kr/ac/pusan/pickle/auth/ReauthTest.java b/src/test/java/kr/ac/pusan/pickle/auth/ReauthTest.java index 0db81783..f02df1c9 100644 --- a/src/test/java/kr/ac/pusan/pickle/auth/ReauthTest.java +++ b/src/test/java/kr/ac/pusan/pickle/auth/ReauthTest.java @@ -196,14 +196,14 @@ void protectedEndpointNeedsTheHeader() throws Exception { long vmId = createVm(); // authenticated, authorized, but no sudo-mode proof → 403 REAUTH_REQUIRED - mockMvc.perform(get("/api/v1/vms/" + vmId + "/password") + mockMvc.perform(get("/api/v1/vms/" + pub("vms", vmId) + "/password") .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isForbidden()) .andExpect(jsonPath("$.code").value("REAUTH_REQUIRED")) .andExpect(jsonPath("$.title").value("재인증이 필요합니다")); // a garbage header is no better than none - mockMvc.perform(get("/api/v1/vms/" + vmId + "/password") + mockMvc.perform(get("/api/v1/vms/" + pub("vms", vmId) + "/password") .header("Authorization", "Bearer " + ownerToken) .header(ReauthInterceptor.REAUTH_HEADER, "not-a-token")) .andExpect(status().isForbidden()) @@ -212,7 +212,7 @@ void protectedEndpointNeedsTheHeader() throws Exception { // with a freshly issued token the request reaches the endpoint's own logic String reauth = ReauthTestSupport.reauthHeaderViaApi(mockMvc, objectMapper, ownerToken, PASSWORD); - mockMvc.perform(get("/api/v1/vms/" + vmId + "/password") + mockMvc.perform(get("/api/v1/vms/" + pub("vms", vmId) + "/password") .header("Authorization", "Bearer " + ownerToken) .header(ReauthInterceptor.REAUTH_HEADER, reauth)) .andExpect(status().isOk()) @@ -226,11 +226,11 @@ void oneTokenCoversSeveralProtectedCallsWithinTheTtl() throws Exception { PASSWORD); // multi-use: one prompt covers a whole sensitive workflow - mockMvc.perform(get("/api/v1/vms/" + vmId + "/password") + mockMvc.perform(get("/api/v1/vms/" + pub("vms", vmId) + "/password") .header("Authorization", "Bearer " + ownerToken) .header(ReauthInterceptor.REAUTH_HEADER, reauth)) .andExpect(status().isOk()); - mockMvc.perform(patch("/api/v1/vms/" + vmId + "/settings") + mockMvc.perform(patch("/api/v1/vms/" + pub("vms", vmId) + "/settings") .header("Authorization", "Bearer " + ownerToken) .header(ReauthInterceptor.REAUTH_HEADER, reauth) .contentType(MediaType.APPLICATION_JSON) @@ -249,7 +249,7 @@ insert into auth_reverifications (user_id, token_hash, token_version, values (?, ?, ?, now() - interval '1 minute', '127.0.0.1') """, owner.getId(), ReauthTestSupport.sha256Hex(rawToken), owner.getTokenVersion()); - mockMvc.perform(get("/api/v1/vms/" + vmId + "/password") + mockMvc.perform(get("/api/v1/vms/" + pub("vms", vmId) + "/password") .header("Authorization", "Bearer " + ownerToken) .header(ReauthInterceptor.REAUTH_HEADER, rawToken)) .andExpect(status().isForbidden()) @@ -265,7 +265,7 @@ void passwordChangeInvalidatesOutstandingTokens() throws Exception { // the token works before the bump (no membership needed: the gate runs // before the endpoint, so a masked 404 already proves it got through) long vmId = createVm(); - mockMvc.perform(get("/api/v1/vms/" + vmId + "/password") + mockMvc.perform(get("/api/v1/vms/" + pub("vms", vmId) + "/password") .header("Authorization", "Bearer " + access) .header(ReauthInterceptor.REAUTH_HEADER, reauth)) .andExpect(status().isNotFound()); @@ -282,7 +282,7 @@ void passwordChangeInvalidatesOutstandingTokens() throws Exception { .andReturn().getResponse().getContentAsString(); String newAccess = objectMapper.readTree(changed).get("accessToken").asString(); - mockMvc.perform(get("/api/v1/vms/" + vmId + "/password") + mockMvc.perform(get("/api/v1/vms/" + pub("vms", vmId) + "/password") .header("Authorization", "Bearer " + newAccess) .header(ReauthInterceptor.REAUTH_HEADER, reauth)) .andExpect(status().isForbidden()) @@ -295,13 +295,13 @@ void anotherUsersTokenIsRejected() throws Exception { String outsiderReauth = ReauthTestSupport.seededReauthHeader(jdbcTemplate, outsider.getId()); - mockMvc.perform(get("/api/v1/vms/" + vmId + "/password") + mockMvc.perform(get("/api/v1/vms/" + pub("vms", vmId) + "/password") .header("Authorization", "Bearer " + ownerToken) .header(ReauthInterceptor.REAUTH_HEADER, outsiderReauth)) .andExpect(status().isForbidden()) .andExpect(jsonPath("$.code").value("REAUTH_REQUIRED")); // and it is the OWNER's own token that unlocks the same call - mockMvc.perform(get("/api/v1/vms/" + vmId + "/password") + mockMvc.perform(get("/api/v1/vms/" + pub("vms", vmId) + "/password") .header("Authorization", "Bearer " + ownerToken) .header(ReauthInterceptor.REAUTH_HEADER, ReauthTestSupport.seededReauthHeader(jdbcTemplate, owner.getId()))) @@ -354,7 +354,7 @@ private long createTeam(String slug) throws Exception { Map.of("kind", "TEAM", "name", "재인증 테스트 " + slug)))) .andExpect(status().isCreated()) .andReturn().getResponse().getContentAsString(); - return objectMapper.readTree(body).get("id").asLong(); + return SeedFixtures.internalId(jdbcTemplate, "workspaces", UUID.fromString(objectMapper.readTree(body).get("id").asString())); } private User ensureUser(String email, String name) { @@ -365,4 +365,9 @@ private User ensureUser(String email, String name) { return userRepository.save(user); }); } + + /** The public identifier of a row this test set up through direct SQL. */ + private UUID pub(String table, long id) { + return SeedFixtures.publicId(jdbcTemplate, table, id); + } } diff --git a/src/test/java/kr/ac/pusan/pickle/campusip/CampusIpRequestTest.java b/src/test/java/kr/ac/pusan/pickle/campusip/CampusIpRequestTest.java index e4334a3d..a5a76f02 100644 --- a/src/test/java/kr/ac/pusan/pickle/campusip/CampusIpRequestTest.java +++ b/src/test/java/kr/ac/pusan/pickle/campusip/CampusIpRequestTest.java @@ -120,7 +120,7 @@ void accessScopingMasksAndRefusesLikePublishing() throws Exception { .andExpect(jsonPath("$.code").value("WORKSPACE_ROLE_INSUFFICIENT")); // reads need only a place on the list: a VIEWER grantee may list mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders - .get("/api/v1/vms/" + vmId + "/campus-ip-requests") + .get("/api/v1/vms/" + pub("vms", vmId) + "/campus-ip-requests") .header("Authorization", "Bearer " + viewerToken)) .andExpect(status().isOk()); } @@ -171,7 +171,7 @@ void onlyOneLiveRequestPerVm() throws Exception { void cancelWorksOnlyBeforeReview() throws Exception { long vmId = vm(); long requestId = created(vmId, "취소 테스트", List.of(80)); - mockMvc.perform(delete("/api/v1/vms/" + vmId + "/campus-ip-requests/" + requestId) + mockMvc.perform(delete("/api/v1/vms/" + pub("vms", vmId) + "/campus-ip-requests/" + pub("campus_ip_requests", requestId)) .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isNoContent()); assertThat(jdbcTemplate.queryForObject( @@ -180,7 +180,7 @@ void cancelWorksOnlyBeforeReview() throws Exception { long second = created(vmId, "취소 불가 테스트", List.of(80)); transition(second, "APPROVED", null).andExpect(status().isOk()); - mockMvc.perform(delete("/api/v1/vms/" + vmId + "/campus-ip-requests/" + second) + mockMvc.perform(delete("/api/v1/vms/" + pub("vms", vmId) + "/campus-ip-requests/" + pub("campus_ip_requests", second)) .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isConflict()) .andExpect(jsonPath("$.code").value("CAMPUS_IP_INVALID_TRANSITION")); @@ -219,10 +219,21 @@ void adminTransitionsFollowTheMatrix() throws Exception { Long notified = jdbcTemplate.queryForObject(""" select count(*) from notifications where user_id = ? and event = 'campus_ip.status_changed' - and (payload ->> 'requestId')::bigint = ? - """, Long.class, owner.getId(), requestId); + and payload ->> 'requestId' = ? + """, Long.class, owner.getId(), pub("campus_ip_requests", requestId).toString()); assertThat(notified).isEqualTo(2); + // The link the notification carries has to be one the console can open. + // Nothing else in the suite asserts link_path, which is how a status + // change kept emitting an internal id after its sibling had stopped. + String linkPath = jdbcTemplate.queryForObject(""" + select link_path from notifications + where user_id = ? and event = 'campus_ip.status_changed' + and payload ->> 'requestId' = ? + order by id desc limit 1 + """, String.class, owner.getId(), pub("campus_ip_requests", requestId).toString()); + assertThat(linkPath).isEqualTo("/console/vms/" + pub("vms", vmId)); + transition(requestId, "REVOKED", null) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value("REVOKED")); @@ -248,7 +259,7 @@ void rejectionIsTerminal() throws Exception { private ResultActions request(long vmId, String token, String purpose, List ports) throws Exception { - return mockMvc.perform(post("/api/v1/vms/" + vmId + "/campus-ip-requests") + return mockMvc.perform(post("/api/v1/vms/" + pub("vms", vmId) + "/campus-ip-requests") .header("Authorization", "Bearer " + token) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString( @@ -259,7 +270,7 @@ private long created(long vmId, String purpose, List ports) throws Exce String body = request(vmId, ownerToken, purpose, ports) .andExpect(status().isCreated()) .andReturn().getResponse().getContentAsString(); - return objectMapper.readTree(body).get("id").asLong(); + return SeedFixtures.internalId(jdbcTemplate, "campus_ip_requests", UUID.fromString(objectMapper.readTree(body).get("id").asString())); } private ResultActions transition(long requestId, String status, String grantedAddress) @@ -267,7 +278,7 @@ private ResultActions transition(long requestId, String status, String grantedAd Map body = grantedAddress == null ? Map.of("status", status) : Map.of("status", status, "grantedAddress", grantedAddress); - return mockMvc.perform(post("/api/v1/admin/campus-ip-requests/" + requestId + "/status") + return mockMvc.perform(post("/api/v1/admin/campus-ip-requests/" + pub("campus_ip_requests", requestId) + "/status") .header("Authorization", "Bearer " + sysAdminToken) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(body))); @@ -309,11 +320,11 @@ private long createTeam(String slug) throws Exception { "slug", slug)))) .andExpect(status().isCreated()) .andReturn().getResponse().getContentAsString(); - return objectMapper.readTree(body).get("id").asLong(); + return SeedFixtures.internalId(jdbcTemplate, "workspaces", UUID.fromString(objectMapper.readTree(body).get("id").asString())); } private void addMember(long workspaceId, String email, String role) throws Exception { - mockMvc.perform(post("/api/v1/workspaces/" + workspaceId + "/members") + mockMvc.perform(post("/api/v1/workspaces/" + pub("workspaces", workspaceId) + "/members") .header("Authorization", "Bearer " + ownerToken) .header(ReauthTestSupport.HEADER, ReauthTestSupport.seededReauthFor(jdbcTemplate, jwtService, @@ -336,4 +347,9 @@ private User ensureUser(String email, String name, UserRole role, Long orgId) { return userRepository.save(user); }); } + + /** The public identifier of a row this test set up through direct SQL. */ + private UUID pub(String table, long id) { + return SeedFixtures.publicId(jdbcTemplate, table, id); + } } diff --git a/src/test/java/kr/ac/pusan/pickle/mfa/MfaAdminResetTest.java b/src/test/java/kr/ac/pusan/pickle/mfa/MfaAdminResetTest.java index 647a0b73..a1842287 100644 --- a/src/test/java/kr/ac/pusan/pickle/mfa/MfaAdminResetTest.java +++ b/src/test/java/kr/ac/pusan/pickle/mfa/MfaAdminResetTest.java @@ -8,6 +8,7 @@ import java.time.Instant; import kr.ac.pusan.pickle.security.JwtService; import kr.ac.pusan.pickle.support.EmbeddedPostgresConfig; +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.UserRole; @@ -49,7 +50,7 @@ void sysAdminResetsEnrollmentAndUnenrolledYields409() throws Exception { String adminToken = jwtService.createAccessToken(sysAdmin); // not enrolled yet → 409 MFA_NOT_ENROLLED - mockMvc.perform(post("/api/v1/admin/users/" + target.getId() + "/mfa-reset") + mockMvc.perform(post("/api/v1/admin/users/" + target.getPublicId() + "/mfa-reset") .header("Authorization", "Bearer " + adminToken)) .andExpect(status().isConflict()) .andExpect(jsonPath("$.code").value("MFA_NOT_ENROLLED")); @@ -58,14 +59,14 @@ void sysAdminResetsEnrollmentAndUnenrolledYields409() throws Exception { assertThat(mfaService.isEnrolled(target.getId())).isTrue(); // reset → 200, enrollment gone - mockMvc.perform(post("/api/v1/admin/users/" + target.getId() + "/mfa-reset") + mockMvc.perform(post("/api/v1/admin/users/" + target.getPublicId() + "/mfa-reset") .header("Authorization", "Bearer " + adminToken)) .andExpect(status().isOk()) .andExpect(jsonPath("$.message").isNotEmpty()); assertThat(mfaService.isEnrolled(target.getId())).isFalse(); // unknown user → 404 - mockMvc.perform(post("/api/v1/admin/users/99999/mfa-reset") + mockMvc.perform(post("/api/v1/admin/users/" + SeedFixtures.UNKNOWN_ID + "/mfa-reset") .header("Authorization", "Bearer " + adminToken)) .andExpect(status().isNotFound()); } @@ -76,7 +77,7 @@ void nonSysAdminForbidden() throws Exception { User target = createUser("mfa.reset.victim@pusan.ac.kr", UserRole.USER); enroll(target.getId()); - mockMvc.perform(post("/api/v1/admin/users/" + target.getId() + "/mfa-reset") + mockMvc.perform(post("/api/v1/admin/users/" + target.getPublicId() + "/mfa-reset") .header("Authorization", "Bearer " + jwtService.createAccessToken(plainUser))) .andExpect(status().isForbidden()); assertThat(mfaService.isEnrolled(target.getId())).isTrue(); diff --git a/src/test/java/kr/ac/pusan/pickle/notification/NotificationTest.java b/src/test/java/kr/ac/pusan/pickle/notification/NotificationTest.java index a1212c96..20e318a8 100644 --- a/src/test/java/kr/ac/pusan/pickle/notification/NotificationTest.java +++ b/src/test/java/kr/ac/pusan/pickle/notification/NotificationTest.java @@ -12,6 +12,7 @@ import kr.ac.pusan.pickle.mail.MockMailSender; import kr.ac.pusan.pickle.security.JwtService; import kr.ac.pusan.pickle.support.EmbeddedPostgresConfig; +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; @@ -104,7 +105,7 @@ select max(id) from notifications where user_id = ? """, Long.class, bob.getId()); // another user's row → 404 (masked), and bob still sees his own - mockMvc.perform(post("/api/v1/notifications/" + bobId + "/read") + mockMvc.perform(post("/api/v1/notifications/" + pub("notifications", bobId) + "/read") .header("Authorization", "Bearer " + aliceToken)) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND")); @@ -114,12 +115,12 @@ select max(id) from notifications where user_id = ? .andExpect(jsonPath("$.totalElements").value(1)); // idempotent read: the first readAt wins, the re-read still answers 200 - String firstRead = mockMvc.perform(post("/api/v1/notifications/" + aliceFirstId + "/read") + String firstRead = mockMvc.perform(post("/api/v1/notifications/" + pub("notifications", aliceFirstId) + "/read") .header("Authorization", "Bearer " + aliceToken)) .andExpect(status().isOk()) .andExpect(jsonPath("$.readAt").isNotEmpty()) .andReturn().getResponse().getContentAsString(); - String secondRead = mockMvc.perform(post("/api/v1/notifications/" + aliceFirstId + "/read") + String secondRead = mockMvc.perform(post("/api/v1/notifications/" + pub("notifications", aliceFirstId) + "/read") .header("Authorization", "Bearer " + aliceToken)) .andExpect(status().isOk()) .andReturn().getResponse().getContentAsString(); @@ -279,4 +280,9 @@ private User ensureUser(String email, String name) { return userRepository.save(user); }); } + + /** The public identifier of a row this test set up through direct SQL. */ + private UUID pub(String table, long id) { + return SeedFixtures.publicId(jdbcTemplate, table, id); + } } diff --git a/src/test/java/kr/ac/pusan/pickle/provisioning/VmExpiryJobTest.java b/src/test/java/kr/ac/pusan/pickle/provisioning/VmExpiryJobTest.java index b5781ed0..c7937e1a 100644 --- a/src/test/java/kr/ac/pusan/pickle/provisioning/VmExpiryJobTest.java +++ b/src/test/java/kr/ac/pusan/pickle/provisioning/VmExpiryJobTest.java @@ -198,11 +198,11 @@ void noticeLadderSelectsSmallestCoveringStageAndRerunsSendNothing() { assertThat(jdbcTemplate.queryForObject(""" select distinct importance from notifications where event = 'vm.expiry.d1' and payload ->> 'vmId' = ? - """, String.class, String.valueOf(vm1))).isEqualTo("HIGH"); + """, String.class, pub("vms", vm1).toString())).isEqualTo("HIGH"); assertThat(jdbcTemplate.queryForObject(""" select distinct importance from notifications where event = 'vm.expiry.d14' and payload ->> 'vmId' = ? - """, String.class, String.valueOf(vm14))).isEqualTo("NORMAL"); + """, String.class, pub("vms", vm14).toString())).isEqualTo("NORMAL"); // hourly re-run: the stage CAS + dedup key make it a complete no-op long before = totalNoticeRows(); @@ -222,7 +222,7 @@ void noticeLadderSelectsSmallestCoveringStageAndRerunsSendNothing() { assertThat(jdbcTemplate.queryForObject(""" select count(distinct payload ->> 'endDate') from notifications where event like 'vm.expiry.d%' and payload ->> 'vmId' = ? - """, Long.class, String.valueOf(vm1))).isEqualTo(2); + """, Long.class, pub("vms", vm1).toString())).isEqualTo(2); } // ── auto-stop ────────────────────────────────────────────────────────── @@ -268,7 +268,7 @@ select count(*) from vm_events assertThat(jdbcTemplate.queryForList(""" select user_id from notifications where event = 'vm.expiry.stopped' and payload ->> 'vmId' = ? - """, Long.class, String.valueOf(vmExpired))) + """, Long.class, pub("vms", vmExpired).toString())) .contains(ownerId, editorId, requesterId) .doesNotContain(memberId); wm.server().verify(postRequestedFor(urlPathEqualTo(qemuPath(vmid, "status/shutdown")))); @@ -408,21 +408,21 @@ private List noticeEvents(long vmId) { return jdbcTemplate.queryForList(""" select distinct event from notifications where event like 'vm.expiry.d%' and payload ->> 'vmId' = ? - """, String.class, String.valueOf(vmId)); + """, String.class, pub("vms", vmId).toString()); } private long noticeCount(long vmId) { return jdbcTemplate.queryForObject(""" select count(*) from notifications where event like 'vm.expiry.%' and payload ->> 'vmId' = ? - """, Long.class, String.valueOf(vmId)); + """, Long.class, pub("vms", vmId).toString()); } private List recipientsOf(long vmId) { return jdbcTemplate.queryForList(""" select user_id from notifications where event like 'vm.expiry.d%' and payload ->> 'vmId' = ? - """, Long.class, String.valueOf(vmId)); + """, Long.class, pub("vms", vmId).toString()); } private long totalNoticeRows() { @@ -449,4 +449,9 @@ private Object column(long vmId, String column) { return jdbcTemplate.queryForObject( "select " + column + " from vms where id = ?", Object.class, vmId); } + + /** The public identifier of a row this test set up through direct SQL. */ + private UUID pub(String table, long id) { + return SeedFixtures.publicId(jdbcTemplate, table, id); + } } diff --git a/src/test/java/kr/ac/pusan/pickle/publishing/PublishingTest.java b/src/test/java/kr/ac/pusan/pickle/publishing/PublishingTest.java index 6b94e29a..1ac25695 100644 --- a/src/test/java/kr/ac/pusan/pickle/publishing/PublishingTest.java +++ b/src/test/java/kr/ac/pusan/pickle/publishing/PublishingTest.java @@ -222,19 +222,19 @@ void publishAuthorizesByTheVmAccessList() throws Exception { String body = "{\"port\":8080,\"subdomain\":\"team-alpha\"}"; // outside the owning workspace → 404 (existence masked) - mockMvc.perform(post("/api/v1/vms/" + vmId + "/domains") + mockMvc.perform(post("/api/v1/vms/" + pub("vms", vmId) + "/domains") .header("Authorization", "Bearer " + outsiderToken) .contentType(MediaType.APPLICATION_JSON).content(body)) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND")); // granted VIEWER on the VM → sees it, may not configure it - mockMvc.perform(post("/api/v1/vms/" + vmId + "/domains") + mockMvc.perform(post("/api/v1/vms/" + pub("vms", vmId) + "/domains") .header("Authorization", "Bearer " + viewerToken) .contentType(MediaType.APPLICATION_JSON).content(body)) .andExpect(status().isForbidden()) .andExpect(jsonPath("$.code").value("WORKSPACE_ROLE_INSUFFICIENT")); // granted EDITOR on the VM → 202 - mockMvc.perform(post("/api/v1/vms/" + vmId + "/domains") + mockMvc.perform(post("/api/v1/vms/" + pub("vms", vmId) + "/domains") .header("Authorization", "Bearer " + editorToken) .contentType(MediaType.APPLICATION_JSON).content(body)) .andExpect(status().isAccepted()) @@ -249,7 +249,7 @@ void publishAuthorizesByTheVmAccessList() throws Exception { @Test void publishRejectsWhenBodyNamesNoSubdomain() throws Exception { long vmId = publishableVm(null, null, VmStatus.RUNNING); - mockMvc.perform(post("/api/v1/vms/" + vmId + "/domains") + mockMvc.perform(post("/api/v1/vms/" + pub("vms", vmId) + "/domains") .header("Authorization", "Bearer " + ownerToken) .contentType(MediaType.APPLICATION_JSON).content("{}")) .andExpect(status().isUnprocessableEntity()) @@ -267,7 +267,7 @@ void publishIgnoresAnyRequestFormName() throws Exception { update vm_request_details set desired_subdomain = 'team-ghost', root_domain = 'pusan.dev' where request_id = (select request_id from vms where id = ?) """, vmId); - mockMvc.perform(post("/api/v1/vms/" + vmId + "/domains") + mockMvc.perform(post("/api/v1/vms/" + pub("vms", vmId) + "/domains") .header("Authorization", "Bearer " + ownerToken) .contentType(MediaType.APPLICATION_JSON).content("{\"port\":80}")) .andExpect(status().isUnprocessableEntity()) @@ -323,7 +323,7 @@ void releasedPlatformNameStaysReservedUntilReturned() throws Exception { // Release: the route goes down but the row stays, holding the name. long domainId = domainIdForVm(first); - mockMvc.perform(delete("/api/v1/domains/" + domainId) + mockMvc.perform(delete("/api/v1/domains/" + pub("domains", domainId)) .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isAccepted()); assertThat(domainStatus(domainId)).isNotEqualTo("REMOVED"); @@ -331,7 +331,7 @@ void releasedPlatformNameStaysReservedUntilReturned() throws Exception { Instant.class, domainId)).isNotNull(); assertThat(routeStatus(routeIdForVm(first))).isEqualTo("REMOVED"); // A released domain has no port to edit — PATCH answers 404. - mockMvc.perform(patch("/api/v1/domains/" + domainId) + mockMvc.perform(patch("/api/v1/domains/" + pub("domains", domainId)) .header("Authorization", "Bearer " + ownerToken) .contentType(MediaType.APPLICATION_JSON).content("{\"port\":81}")) .andExpect(status().isNotFound()); @@ -343,7 +343,7 @@ void releasedPlatformNameStaysReservedUntilReturned() throws Exception { // DELETE on the reserved (non-serving) row returns the name NOW — and // the release stamp goes with the claim: a REMOVED row reserves // nothing, so it must stop reading as "reserved". - mockMvc.perform(delete("/api/v1/domains/" + domainId) + mockMvc.perform(delete("/api/v1/domains/" + pub("domains", domainId)) .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isAccepted()); assertThat(domainStatus(domainId)).isEqualTo("REMOVED"); @@ -359,7 +359,7 @@ void reAddingAReleasedNameOnTheSameVmRevivesTheRow() throws Exception { long vmId = publishableVm("team-revive", "pusan.dev", VmStatus.RUNNING); publish(vmId, "{\"port\":80}").andExpect(status().isAccepted()); long domainId = domainIdForVm(vmId); - mockMvc.perform(delete("/api/v1/domains/" + domainId) + mockMvc.perform(delete("/api/v1/domains/" + pub("domains", domainId)) .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isAccepted()); @@ -367,7 +367,7 @@ void reAddingAReleasedNameOnTheSameVmRevivesTheRow() throws Exception { publish(vmId, "{\"port\":8081,\"subdomain\":\"team-revive\"}") .andExpect(status().isAccepted()) .andExpect(jsonPath("$.fqdn").value("team-revive.pusan.dev")) - .andExpect(jsonPath("$.domain.id").value(domainId)) + .andExpect(jsonPath("$.domain.id").value(pub("domains", domainId).toString())) .andExpect(jsonPath("$.route.targetPort").value(8081)); assertThat(jdbcTemplate.queryForObject("select released_at from domains where id = ?", Instant.class, domainId)).isNull(); @@ -384,10 +384,10 @@ void domainListingScopesToOwnWorkspacesAndHidesRemovedByDefault() throws Excepti publish(vmId, "{\"port\":80,\"subdomain\":\"team-ulist-gone\"}") .andExpect(status().isAccepted()); long goneId = domainIdForVm(vmId); - mockMvc.perform(delete("/api/v1/domains/" + goneId) + mockMvc.perform(delete("/api/v1/domains/" + pub("domains", goneId)) .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isAccepted()); - mockMvc.perform(delete("/api/v1/domains/" + goneId) + mockMvc.perform(delete("/api/v1/domains/" + pub("domains", goneId)) .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isAccepted()); assertThat(domainStatus(goneId)).isEqualTo("REMOVED"); @@ -397,32 +397,32 @@ void domainListingScopesToOwnWorkspacesAndHidesRemovedByDefault() throws Excepti // Default listing: own workspaces only, REMOVED hidden — a removed domain // is gone from its owner's view, not a row lingering as "reserved". - Map defaults = listDomains(ownerToken, ""); - assertThat(defaults).containsKey(liveId); - assertThat(defaults).doesNotContainKey(goneId); - assertThat(defaults).doesNotContainKey(foreignId); + Map defaults = listDomains(ownerToken, ""); + assertThat(defaults).containsKey(pub("domains", liveId)); + assertThat(defaults).doesNotContainKey(pub("domains", goneId)); + assertThat(defaults).doesNotContainKey(pub("domains", foreignId)); // Explicit status=REMOVED opts in (same convention as the admin // listing) — still scoped, and the row carries no release stamp. - Map removed = + Map removed = listDomains(ownerToken, "&status=REMOVED"); - assertThat(removed).containsKey(goneId); - assertThat(removed).doesNotContainKey(liveId); - assertThat(removed).doesNotContainKey(foreignId); - assertThat(removed.get(goneId).get("releasedAt").isNull()).isTrue(); + assertThat(removed).containsKey(pub("domains", goneId)); + assertThat(removed).doesNotContainKey(pub("domains", liveId)); + assertThat(removed).doesNotContainKey(pub("domains", foreignId)); + assertThat(removed.get(pub("domains", goneId)).get("releasedAt").isNull()).isTrue(); // The foreign workspace's member sees their own row, never this workspace's. - Map outsider = listDomains(outsiderToken, ""); - assertThat(outsider).containsKey(foreignId); - assertThat(outsider).doesNotContainKey(liveId); + Map outsider = listDomains(outsiderToken, ""); + assertThat(outsider).containsKey(pub("domains", foreignId)); + assertThat(outsider).doesNotContainKey(pub("domains", liveId)); // Belonging to the owning workspace is no longer enough to see what its // VMs publish: the listing is scoped to the VMs the caller can actually // reach, and reaching one means being named on its access list. The // contrast pins the rule down — same workspace, same VM, and the only // difference is a grant, of which the lowest rung already suffices. - assertThat(listDomains(bystanderToken, "")).doesNotContainKey(liveId); - assertThat(listDomains(viewerToken, "")).containsKey(liveId); + assertThat(listDomains(bystanderToken, "")).doesNotContainKey(pub("domains", liveId)); + assertThat(listDomains(viewerToken, "")).containsKey(pub("domains", liveId)); } @Test @@ -430,16 +430,16 @@ void domainListingKeepsAReservedRowVisibleWithItsStamp() throws Exception { long vmId = publishableVm("team-ulist-res", "pusan.dev", VmStatus.RUNNING); publish(vmId, "{\"port\":80}").andExpect(status().isAccepted()); long domainId = domainIdForVm(vmId); - mockMvc.perform(delete("/api/v1/domains/" + domainId) + mockMvc.perform(delete("/api/v1/domains/" + pub("domains", domainId)) .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isAccepted()); // Released, not removed: the row stays listed by default and carries // the stamp + computed reservation end the console renders. - Map defaults = listDomains(ownerToken, ""); - assertThat(defaults).containsKey(domainId); - assertThat(defaults.get(domainId).get("releasedAt").isNull()).isFalse(); - assertThat(defaults.get(domainId).get("reservedUntil").isNull()).isFalse(); + Map defaults = listDomains(ownerToken, ""); + assertThat(defaults).containsKey(pub("domains", domainId)); + assertThat(defaults.get(pub("domains", domainId)).get("releasedAt").isNull()).isFalse(); + assertThat(defaults.get(pub("domains", domainId)).get("reservedUntil").isNull()).isFalse(); } @Test @@ -447,7 +447,7 @@ void aReviveRacingTheSweepersReclaimNeverRevivesARemovedRow() throws Exception { long vmId = publishableVm("team-rrace", "pusan.dev", VmStatus.RUNNING); publish(vmId, "{\"port\":80}").andExpect(status().isAccepted()); long domainId = domainIdForVm(vmId); - mockMvc.perform(delete("/api/v1/domains/" + domainId) + mockMvc.perform(delete("/api/v1/domains/" + pub("domains", domainId)) .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isAccepted()); // reserved now @@ -515,7 +515,7 @@ insert into certificates (domain_id, kind, scope, not_after, status) long vmId = publishableVm("team-revcheck", "revive-root.example", VmStatus.RUNNING); publish(vmId, "{\"port\":80}").andExpect(status().isAccepted()); long domainId = domainIdForVm(vmId); - mockMvc.perform(delete("/api/v1/domains/" + domainId) + mockMvc.perform(delete("/api/v1/domains/" + pub("domains", domainId)) .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isAccepted()); assertThat(domainStatus(domainId)).isNotEqualTo("REMOVED"); // reserved @@ -542,7 +542,7 @@ insert into certificates (domain_id, kind, scope, not_after, status) @Test void publishRejectsPort22() throws Exception { long vmId = publishableVm("team-ssh", "pusan.dev", VmStatus.RUNNING); - mockMvc.perform(post("/api/v1/vms/" + vmId + "/domains") + mockMvc.perform(post("/api/v1/vms/" + pub("vms", vmId) + "/domains") .header("Authorization", "Bearer " + ownerToken) .contentType(MediaType.APPLICATION_JSON).content("{\"port\":22}")) .andExpect(status().isUnprocessableEntity()) @@ -553,7 +553,7 @@ void publishRejectsPort22() throws Exception { @Test void publishRejectsCustomDomainUnderPlatformZone() throws Exception { long vmId = publishableVm("team-custom", "pusan.dev", VmStatus.RUNNING); - mockMvc.perform(post("/api/v1/vms/" + vmId + "/domains") + mockMvc.perform(post("/api/v1/vms/" + pub("vms", vmId) + "/domains") .header("Authorization", "Bearer " + ownerToken) .contentType(MediaType.APPLICATION_JSON) .content("{\"port\":80,\"customDomain\":\"squat.pusan.dev\"}")) @@ -574,7 +574,7 @@ void aVmServesSeveralDomainsEachWithItsOwnPort() throws Exception { .andExpect(status().isAccepted()); // VmDetail lists every serving domain, id ascending, each with its port. - mockMvc.perform(get("/api/v1/vms/" + vmId) + mockMvc.perform(get("/api/v1/vms/" + pub("vms", vmId)) .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isOk()) .andExpect(jsonPath("$.publications.length()").value(3)) @@ -587,7 +587,7 @@ void aVmServesSeveralDomainsEachWithItsOwnPort() throws Exception { // A port edit targets ONE domain and leaves the siblings alone. long firstDomain = jdbcTemplate.queryForObject( "select id from domains where fqdn = 'team-multi-a.pusan.dev'", Long.class); - mockMvc.perform(patch("/api/v1/domains/" + firstDomain) + mockMvc.perform(patch("/api/v1/domains/" + pub("domains", firstDomain)) .header("Authorization", "Bearer " + ownerToken) .contentType(MediaType.APPLICATION_JSON).content("{\"port\":9090}")) .andExpect(status().isAccepted()) @@ -598,14 +598,14 @@ void aVmServesSeveralDomainsEachWithItsOwnPort() throws Exception { """, Integer.class)).isEqualTo(8081); // Deleting one domain leaves the other routes untouched. - mockMvc.perform(delete("/api/v1/domains/" + firstDomain) + mockMvc.perform(delete("/api/v1/domains/" + pub("domains", firstDomain)) .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isAccepted()); assertThat(jdbcTemplate.queryForObject(""" select r.status from routes r join domains d on d.id = r.domain_id where d.fqdn = 'team-multi-b.pusan.dev' """, String.class)).isEqualTo("PENDING"); - mockMvc.perform(get("/api/v1/vms/" + vmId) + mockMvc.perform(get("/api/v1/vms/" + pub("vms", vmId)) .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isOk()) .andExpect(jsonPath("$.publications.length()").value(2)); @@ -637,7 +637,7 @@ on conflict (key) do update set value = excluded.value // the replacement. long released = jdbcTemplate.queryForObject( "select id from domains where fqdn = 'team-cap-a.pusan.dev'", Long.class); - mockMvc.perform(delete("/api/v1/domains/" + released) + mockMvc.perform(delete("/api/v1/domains/" + pub("domains", released)) .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isAccepted()); publish(vmId, "{\"port\":80,\"subdomain\":\"team-cap-c\"}") @@ -783,7 +783,7 @@ insert into certificates (domain_id, kind, scope, not_after, status) publish(vmId, "{\"port\":80}").andExpect(status().isAccepted()); - mockMvc.perform(get("/api/v1/vms/" + vmId) + mockMvc.perform(get("/api/v1/vms/" + pub("vms", vmId)) .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isOk()) .andExpect(jsonPath("$.publications[0].fqdn") @@ -906,21 +906,21 @@ void verifyTriggerIsRateLimitedAndDeduplicated() throws Exception { long enqueued = verifyJobCount(); try { - mockMvc.perform(post("/api/v1/domains/" + domainId + "/verify") + mockMvc.perform(post("/api/v1/domains/" + pub("domains", domainId) + "/verify") .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isAccepted()); - mockMvc.perform(post("/api/v1/domains/" + domainId + "/verify") + mockMvc.perform(post("/api/v1/domains/" + pub("domains", domainId) + "/verify") .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isAccepted()); assertThat(verifyJobCount()).isEqualTo(enqueued); // per-user sliding window (10/min): the 11th trigger is rejected for (int i = 0; i < 8; i++) { - mockMvc.perform(post("/api/v1/domains/" + domainId + "/verify") + mockMvc.perform(post("/api/v1/domains/" + pub("domains", domainId) + "/verify") .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isAccepted()); } - mockMvc.perform(post("/api/v1/domains/" + domainId + "/verify") + mockMvc.perform(post("/api/v1/domains/" + pub("domains", domainId) + "/verify") .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isTooManyRequests()) .andExpect(jsonPath("$.code").value("RATE_LIMITED")); @@ -937,7 +937,7 @@ void verifyTriggerMasksARemovedDomainLikeUpdateAndDelete() throws Exception { publish(vmId, "{\"port\":3000,\"customDomain\":\"" + fqdn + "\"}") .andExpect(status().isAccepted()); long domainId = domainIdForVm(vmId); - mockMvc.perform(delete("/api/v1/domains/" + domainId) + mockMvc.perform(delete("/api/v1/domains/" + pub("domains", domainId)) .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isAccepted()); assertThat(domainStatus(domainId)).isEqualTo("REMOVED"); @@ -946,7 +946,7 @@ void verifyTriggerMasksARemovedDomainLikeUpdateAndDelete() throws Exception { // A removed row answers the same 404 the other mutating ops give — it // must not accept a verify (202), enqueue a DNS job, or burn the // caller's rate-limit budget. - mockMvc.perform(post("/api/v1/domains/" + domainId + "/verify") + mockMvc.perform(post("/api/v1/domains/" + pub("domains", domainId) + "/verify") .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND")); @@ -1054,13 +1054,13 @@ void adminRoutesAreOrgScoped() throws Exception { // ORG_ADMIN of the seed org sees the route; a regular user cannot reach the admin list. mockMvc.perform(get("/api/v1/admin/routes").header("Authorization", "Bearer " + orgAdminToken)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.content[?(@.vmId == %d)]".formatted(vmId)).exists()); + .andExpect(jsonPath("$.content[?(@.vmId == \'%s\')]".formatted(pub("vms", vmId))).exists()); mockMvc.perform(get("/api/v1/admin/routes").header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isForbidden()); // Admin domains + certificates render (wildcard cert is visible to all admins). mockMvc.perform(get("/api/v1/admin/domains").header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.content[?(@.vmId == %d)]".formatted(vmId)).exists()); + .andExpect(jsonPath("$.content[?(@.vmId == \'%s\')]".formatted(pub("vms", vmId))).exists()); mockMvc.perform(get("/api/v1/admin/certificates") .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isOk()) @@ -1113,7 +1113,7 @@ void resyncDoesNotResurrectARouteReleasedMidSync() throws Exception { // row starts its reservation grace. MID_CALL_HOOK.set(() -> { try { - mockMvc.perform(delete("/api/v1/domains/" + domainId) + mockMvc.perform(delete("/api/v1/domains/" + pub("domains", domainId)) .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isAccepted()); } catch (Exception e) { @@ -1142,7 +1142,7 @@ void adminDomainRemovedFilterAndFailedCertHideExpiry() throws Exception { publish(vmId, "{\"port\":80,\"customDomain\":\"" + fqdn + "\"}") .andExpect(status().isAccepted()); long domainId = domainIdForVm(vmId); - mockMvc.perform(delete("/api/v1/domains/" + domainId) + mockMvc.perform(delete("/api/v1/domains/" + pub("domains", domainId)) .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isAccepted()); assertThat(domainStatus(domainId)).isEqualTo("REMOVED"); @@ -1151,11 +1151,11 @@ void adminDomainRemovedFilterAndFailedCertHideExpiry() throws Exception { mockMvc.perform(get("/api/v1/admin/domains?status=REMOVED") .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.content[?(@.id == %d)]".formatted(domainId)).exists()); + .andExpect(jsonPath("$.content[?(@.id == '%s')]".formatted(pub("domains", domainId))).exists()); mockMvc.perform(get("/api/v1/admin/domains") .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.content[?(@.id == %d)]".formatted(domainId)).doesNotExist()); + .andExpect(jsonPath("$.content[?(@.id == '%s')]".formatted(pub("domains", domainId))).doesNotExist()); // a FAILED cert reports no expiry countdown even with a stale notAfter long certId = jdbcTemplate.queryForObject(""" @@ -1166,9 +1166,10 @@ void adminDomainRemovedFilterAndFailedCertHideExpiry() throws Exception { .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isOk()) .andReturn().getResponse().getContentAsString(); + UUID certPublicId = pub("certificates", certId); tools.jackson.databind.JsonNode cert = null; for (tools.jackson.databind.JsonNode node : objectMapper.readTree(body).get("content")) { - if (node.get("id").asLong() == certId) { + if (certPublicId.toString().equals(node.get("id").asString())) { cert = node; } } @@ -1185,21 +1186,21 @@ void adminDomainListingSeparatesAReservedNameFromAServingOne() throws Exception publish(vmId, "{\"port\":80,\"subdomain\":\"team-admres-gone\"}") .andExpect(status().isAccepted()); long reservedId = domainIdForVm(vmId); - mockMvc.perform(delete("/api/v1/domains/" + reservedId) + mockMvc.perform(delete("/api/v1/domains/" + pub("domains", reservedId)) .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isAccepted()); // released → reserved // Both rows are listed and both read ACTIVE — the reservation stamp is // the only thing telling an admin why the second name is still taken. - Map byId = listAdminDomains(); - assertThat(byId.get(servingId).get("status").asString()).isEqualTo("ACTIVE"); - assertThat(byId.get(servingId).get("releasedAt").isNull()).isTrue(); - assertThat(byId.get(servingId).get("reservedUntil").isNull()).isTrue(); - assertThat(byId.get(reservedId).get("status").asString()).isEqualTo("ACTIVE"); - assertThat(byId.get(reservedId).get("releasedAt").isNull()).isFalse(); + Map byId = listAdminDomains(); + assertThat(byId.get(pub("domains", servingId)).get("status").asString()).isEqualTo("ACTIVE"); + assertThat(byId.get(pub("domains", servingId)).get("releasedAt").isNull()).isTrue(); + assertThat(byId.get(pub("domains", servingId)).get("reservedUntil").isNull()).isTrue(); + assertThat(byId.get(pub("domains", reservedId)).get("status").asString()).isEqualTo("ACTIVE"); + assertThat(byId.get(pub("domains", reservedId)).get("releasedAt").isNull()).isFalse(); // The reservation end is the server's own arithmetic (grace setting), // not something the console could derive from releasedAt. - assertThat(byId.get(reservedId).get("reservedUntil").isNull()).isFalse(); + assertThat(byId.get(pub("domains", reservedId)).get("reservedUntil").isNull()).isFalse(); } // ── transport-failure retry + recurring reconcile (hardening) ─────────── @@ -1230,7 +1231,7 @@ void unpublishWithUnreachableAgentIsReconciledToAbsent() throws Exception { agent.resetAll(); agent.stubFor(com.github.tomakehurst.wiremock.client.WireMock.post(urlPathEqualTo(APPLY_PATH)) .willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER))); - mockMvc.perform(delete("/api/v1/domains/" + domainIdForVm(vmId)) + mockMvc.perform(delete("/api/v1/domains/" + pub("domains", domainIdForVm(vmId))) .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isAccepted()); assertThat(routeStatus(routeId)).isEqualTo("REMOVED"); @@ -1344,7 +1345,7 @@ void anEqualGenerationRefusalOfARemovalRebumpsAndReconverges() throws Exception // Release the domain, then push the removal into a 409 carrying the // SAME generation — the agent's refusal when its applied generation // already equals ours (a duplicate whose earlier response was lost). - mockMvc.perform(delete("/api/v1/domains/" + domainIdForVm(vmId)) + mockMvc.perform(delete("/api/v1/domains/" + pub("domains", domainIdForVm(vmId))) .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isAccepted()); long removedGen = routeGeneration(routeId); @@ -1410,7 +1411,7 @@ private void deleteWithAgentDown(long domainId, long routeId) throws Exception { agent.resetAll(); agent.stubFor(com.github.tomakehurst.wiremock.client.WireMock.post(urlPathEqualTo(APPLY_PATH)) .willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER))); - mockMvc.perform(delete("/api/v1/domains/" + domainId) + mockMvc.perform(delete("/api/v1/domains/" + pub("domains", domainId)) .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isAccepted()); assertThatThrownBy(() -> routeApplyJob.apply(routeId)) @@ -1501,7 +1502,7 @@ void unreachableAgentOnTeardownBlocksDeletionAndIpRelease() throws Exception { long vmId = publishableVm("team-delnet", "pusan.dev", VmStatus.RUNNING); publish(vmId, "{\"port\":8080}").andExpect(status().isAccepted()); routeApplyJob.apply(routeIdForVm(vmId)); - mockMvc.perform(delete("/api/v1/vms/" + vmId) + mockMvc.perform(delete("/api/v1/vms/" + pub("vms", vmId)) .header("Authorization", "Bearer " + ownerToken) .header(ReauthTestSupport.HEADER, reauth(ownerToken))) .andExpect(status().isAccepted()); @@ -1527,14 +1528,14 @@ void deleteOfServingPlatformDomainRemovesRouteButKeepsTheRow() throws Exception publish(vmId, "{\"port\":80}").andExpect(status().isAccepted()); long routeId = routeIdForVm(vmId); long domainId = domainIdForVm(vmId); - mockMvc.perform(delete("/api/v1/domains/" + domainId) + mockMvc.perform(delete("/api/v1/domains/" + pub("domains", domainId)) .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isAccepted()); assertThat(routeStatus(routeId)).isEqualTo("REMOVED"); // The platform row survives, reserving the name through the grace; the // detail view exposes the reservation window the server computed. assertThat(domainStatus(domainId)).isNotEqualTo("REMOVED"); - mockMvc.perform(get("/api/v1/domains/" + domainId) + mockMvc.perform(get("/api/v1/domains/" + pub("domains", domainId)) .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isOk()) .andExpect(jsonPath("$.releasedAt").isNotEmpty()) @@ -1586,7 +1587,7 @@ void customDomainDeleteRemovesImmediatelyAndFreesTheName() throws Exception { .andExpect(status().isAccepted()); long domainId = domainIdForVm(vmId); - mockMvc.perform(delete("/api/v1/domains/" + domainId) + mockMvc.perform(delete("/api/v1/domains/" + pub("domains", domainId)) .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isAccepted()); assertThat(domainStatus(domainId)).isEqualTo("REMOVED"); @@ -1594,12 +1595,12 @@ void customDomainDeleteRemovesImmediatelyAndFreesTheName() throws Exception { select count(*) from certificates where domain_id = ? and status <> 'REVOKED' """, Long.class, domainId)).isZero(); // The VM is no longer published for this name. - mockMvc.perform(get("/api/v1/vms/" + vmId) + mockMvc.perform(get("/api/v1/vms/" + pub("vms", vmId)) .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isOk()) .andExpect(jsonPath("$.publications.length()").value(0)); // A second DELETE answers 404 (already removed). - mockMvc.perform(delete("/api/v1/domains/" + domainId) + mockMvc.perform(delete("/api/v1/domains/" + pub("domains", domainId)) .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isNotFound()); @@ -1618,7 +1619,7 @@ void portOnlyUpdateReappliesSameDomain() throws Exception { long routeId = routeIdForVm(vmId); long genBefore = jdbcTemplate.queryForObject("select generation from routes where id = ?", Long.class, routeId); - mockMvc.perform(patch("/api/v1/domains/" + domainIdForVm(vmId)) + mockMvc.perform(patch("/api/v1/domains/" + pub("domains", domainIdForVm(vmId))) .header("Authorization", "Bearer " + ownerToken) .contentType(MediaType.APPLICATION_JSON).content("{\"port\":3000}")) .andExpect(status().isAccepted()) @@ -1648,7 +1649,7 @@ void deletePipelineTearsDownPublishingBeforeIpRelease() throws Exception { // Self-delete accepted, then run the destroy pipeline directly. The vmid // is nulled so the (unstubbed) Proxmox destroy step skips, and the // grace deadline is fast-forwarded — the pipeline refuses undue intents. - mockMvc.perform(delete("/api/v1/vms/" + vmId) + mockMvc.perform(delete("/api/v1/vms/" + pub("vms", vmId)) .header("Authorization", "Bearer " + ownerToken) .header(ReauthTestSupport.HEADER, reauth(ownerToken))) .andExpect(status().isAccepted()); @@ -1734,7 +1735,7 @@ void failedTeardownBlocksDeletionAndIpRelease() throws Exception { long vmId = publishableVm("team-delfail", "pusan.dev", VmStatus.RUNNING); publish(vmId, "{\"port\":8080}").andExpect(status().isAccepted()); routeApplyJob.apply(routeIdForVm(vmId)); - mockMvc.perform(delete("/api/v1/vms/" + vmId) + mockMvc.perform(delete("/api/v1/vms/" + pub("vms", vmId)) .header("Authorization", "Bearer " + ownerToken) .header(ReauthTestSupport.HEADER, reauth(ownerToken))) .andExpect(status().isAccepted()); @@ -1785,7 +1786,7 @@ void adminForceReleaseFreesTheNameAtOnceAndCannotBeRevived() throws Exception { // user's own release gets: the row goes at once. Were it merely reserved, // the owner could re-add the same name and revive the very row the admin // just took down. - mockMvc.perform(post("/api/v1/admin/domains/" + domainId + "/force-release") + mockMvc.perform(post("/api/v1/admin/domains/" + pub("domains", domainId) + "/force-release") .header("Authorization", "Bearer " + orgAdminToken)) .andExpect(status().isOk()); assertThat(routeStatus(routeId)).isEqualTo("REMOVED"); @@ -1795,7 +1796,7 @@ void adminForceReleaseFreesTheNameAtOnceAndCannotBeRevived() throws Exception { // keep telling the owner it is reserved until some future date. assertThat(jdbcTemplate.queryForObject("select released_at from domains where id = ?", Instant.class, domainId)).isNull(); - assertThat(auditCount("domain.force_release", domainId)).isEqualTo(1); + assertThat(auditCount("domain.force_release", "domains", domainId)).isEqualTo(1); // Re-adding the same name builds a new row rather than reviving the old // one — the takedown stands in the audit trail. @@ -1803,7 +1804,7 @@ void adminForceReleaseFreesTheNameAtOnceAndCannotBeRevived() throws Exception { assertThat(domainIdForVm(vmId)).isNotEqualTo(domainId); // an already-REMOVED domain answers the same 404 as an unknown id - mockMvc.perform(post("/api/v1/admin/domains/" + domainId + "/force-release") + mockMvc.perform(post("/api/v1/admin/domains/" + pub("domains", domainId) + "/force-release") .header("Authorization", "Bearer " + orgAdminToken)) .andExpect(status().isNotFound()); } @@ -1814,7 +1815,7 @@ void adminForceReleaseNotifiesTheOwningWorkspaceAndLogsTheVmEvent() throws Excep publish(vmId, "{\"port\":80}").andExpect(status().isAccepted()); long servingId = domainIdForVm(vmId); - mockMvc.perform(post("/api/v1/admin/domains/" + servingId + "/force-release") + mockMvc.perform(post("/api/v1/admin/domains/" + pub("domains", servingId) + "/force-release") .header("Authorization", "Bearer " + orgAdminToken)) .andExpect(status().isOk()); // The owning workspace is told, same channel as an admin mapping delete: @@ -1835,10 +1836,10 @@ select count(*) from vm_events publish(vmId, "{\"port\":80,\"subdomain\":\"team-fnote2\"}") .andExpect(status().isAccepted()); long reservedId = domainIdForVm(vmId); - mockMvc.perform(delete("/api/v1/domains/" + reservedId) + mockMvc.perform(delete("/api/v1/domains/" + pub("domains", reservedId)) .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isAccepted()); // released → reserved - mockMvc.perform(post("/api/v1/admin/domains/" + reservedId + "/force-release") + mockMvc.perform(post("/api/v1/admin/domains/" + pub("domains", reservedId) + "/force-release") .header("Authorization", "Bearer " + orgAdminToken)) .andExpect(status().isOk()); assertThat(adminReleaseNoticeCount("team-fnote2.pusan.dev")).isEqualTo(1); @@ -1863,24 +1864,21 @@ void adminDomainInterventionIsOrgScopedWithThe404Mask() throws Exception { long domainId = domainIdForVm(vmId); long routeId = routeIdForVm(vmId); - long otherOrgId = jdbcTemplate.queryForObject(""" - insert into orgs (name, slug) values ('개입 타기관', 'pub-other-org') - on conflict (slug) do update set name = excluded.name returning id - """, Long.class); + long otherOrgId = ensureOrg("개입 타기관"); User otherOrgAdmin = ensureUser("pub.other.admin@pusan.ac.kr", "타기관관리자", UserRole.ORG_ADMIN, otherOrgId); String otherToken = jwtService.createAccessToken(otherOrgAdmin); - mockMvc.perform(post("/api/v1/admin/domains/" + domainId + "/force-release") + mockMvc.perform(post("/api/v1/admin/domains/" + pub("domains", domainId) + "/force-release") .header("Authorization", "Bearer " + otherToken)) .andExpect(status().isNotFound()); - mockMvc.perform(post("/api/v1/admin/routes/" + routeId + "/apply") + mockMvc.perform(post("/api/v1/admin/routes/" + pub("routes", routeId) + "/apply") .header("Authorization", "Bearer " + otherToken)) .andExpect(status().isNotFound()); assertThat(domainStatus(domainId)).isNotEqualTo("REMOVED"); // a plain user is refused by the role gate - mockMvc.perform(post("/api/v1/admin/domains/" + domainId + "/force-release") + mockMvc.perform(post("/api/v1/admin/domains/" + pub("domains", domainId) + "/force-release") .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isForbidden()); } @@ -1893,10 +1891,10 @@ void adminVerifyTriggersWithoutUserRateLimitAndRejectsPlatformDomains() throws E .andExpect(status().isAccepted()); long domainId = domainIdForVm(vmId); - mockMvc.perform(post("/api/v1/admin/domains/" + domainId + "/verify") + mockMvc.perform(post("/api/v1/admin/domains/" + pub("domains", domainId) + "/verify") .header("Authorization", "Bearer " + orgAdminToken)) .andExpect(status().isAccepted()); - assertThat(auditCount("domain.admin_verify", domainId)).isEqualTo(1); + assertThat(auditCount("domain.admin_verify", "domains", domainId)).isEqualTo(1); // no per-user rate-limit row is consumed by the admin trigger assertThat(jdbcTemplate.queryForObject(""" select count(*) from auth_rate_limits where scope = 'domain_verify' @@ -1904,7 +1902,7 @@ select count(*) from auth_rate_limits where scope = 'domain_verify' long platformVm = publishableVm("team-avplat", "pusan.dev", VmStatus.RUNNING); publish(platformVm, "{\"port\":80}").andExpect(status().isAccepted()); - mockMvc.perform(post("/api/v1/admin/domains/" + domainIdForVm(platformVm) + "/verify") + mockMvc.perform(post("/api/v1/admin/domains/" + pub("domains", domainIdForVm(platformVm)) + "/verify") .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isConflict()) .andExpect(jsonPath("$.code").value("DOMAIN_NOT_CUSTOM")); @@ -1922,13 +1920,13 @@ void adminRouteApplyRefusesUnverifiedCustomDomains() throws Exception { long routeId = routeIdForVm(vmId); long generationBefore = routeGeneration(routeId); - mockMvc.perform(post("/api/v1/admin/routes/" + routeId + "/apply") + mockMvc.perform(post("/api/v1/admin/routes/" + pub("routes", routeId) + "/apply") .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isConflict()) .andExpect(jsonPath("$.code").value("DOMAIN_NOT_ACTIVE")); // untouched: no generation bump, no audit, route stays PENDING assertThat(routeGeneration(routeId)).isEqualTo(generationBefore); - assertThat(auditCount("route.apply", routeId)).isZero(); + assertThat(auditCount("route.apply", "routes", routeId)).isZero(); assertThat(routeStatus(routeId)).isEqualTo("PENDING"); } @@ -1940,19 +1938,19 @@ void adminRouteApplyBumpsGenerationAndRepushesRemoval() throws Exception { long domainId = domainIdForVm(vmId); long generationBefore = routeGeneration(routeId); - mockMvc.perform(post("/api/v1/admin/routes/" + routeId + "/apply") + mockMvc.perform(post("/api/v1/admin/routes/" + pub("routes", routeId) + "/apply") .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isAccepted()); assertThat(routeGeneration(routeId)).isGreaterThan(generationBefore); assertThat(routeStatus(routeId)).isEqualTo("PENDING"); - assertThat(auditCount("route.apply", routeId)).isEqualTo(1); + assertThat(auditCount("route.apply", "routes", routeId)).isEqualTo(1); // a REMOVED route re-pushes its removal (desired state), status stays REMOVED - mockMvc.perform(post("/api/v1/admin/domains/" + domainId + "/force-release") + mockMvc.perform(post("/api/v1/admin/domains/" + pub("domains", domainId) + "/force-release") .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isOk()); long generationAfterRelease = routeGeneration(routeId); - mockMvc.perform(post("/api/v1/admin/routes/" + routeId + "/apply") + mockMvc.perform(post("/api/v1/admin/routes/" + pub("routes", routeId) + "/apply") .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isAccepted()); assertThat(routeStatus(routeId)).isEqualTo("REMOVED"); @@ -1964,10 +1962,10 @@ private long routeGeneration(long routeId) { Long.class, routeId); } - private long auditCount(String action, long targetId) { + private long auditCount(String action, String table, long targetId) { return jdbcTemplate.queryForObject( "select count(*) from audit_logs where action = ? and target_id = ?", - Long.class, action, targetId); + Long.class, action, pub(table, targetId).toString()); } // ── helpers ────────────────────────────────────────────────────────────── @@ -1985,7 +1983,7 @@ private org.springframework.test.web.servlet.ResultActions publish(long vmId, St body = body.substring(0, body.length() - 1) + extra + "}"; } } - return mockMvc.perform(post("/api/v1/vms/" + vmId + "/domains") + return mockMvc.perform(post("/api/v1/vms/" + pub("vms", vmId) + "/domains") .header("Authorization", "Bearer " + ownerToken) .contentType(MediaType.APPLICATION_JSON).content(body)); } @@ -2003,27 +2001,27 @@ private long domainIdForVm(long vmId) { } /** GET /admin/domains as SYS_ADMIN; rows of the page keyed by domain id. */ - private Map listAdminDomains() throws Exception { + private Map listAdminDomains() throws Exception { String body = mockMvc.perform(get("/api/v1/admin/domains?size=100") .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isOk()) .andReturn().getResponse().getContentAsString(); - Map byId = new java.util.HashMap<>(); + Map byId = new java.util.HashMap<>(); objectMapper.readTree(body).get("content") - .forEach(node -> byId.put(node.get("id").asLong(), node)); + .forEach(node -> byId.put(UUID.fromString(node.get("id").asString()), node)); return byId; } /** GET /domains as the given caller; rows of the page keyed by domain id. */ - private Map listDomains(String token, String extraQuery) + private Map listDomains(String token, String extraQuery) throws Exception { String body = mockMvc.perform(get("/api/v1/domains?size=100" + extraQuery) .header("Authorization", "Bearer " + token)) .andExpect(status().isOk()) .andReturn().getResponse().getContentAsString(); - Map byId = new java.util.HashMap<>(); + Map byId = new java.util.HashMap<>(); objectMapper.readTree(body).get("content") - .forEach(node -> byId.put(node.get("id").asLong(), node)); + .forEach(node -> byId.put(UUID.fromString(node.get("id").asString()), node)); return byId; } @@ -2158,7 +2156,7 @@ private long createTeam(String slug) throws Exception { Map.of("kind", "TEAM", "name", "공개 테스트 " + slug)))) .andExpect(status().isCreated()) .andReturn().getResponse().getContentAsString(); - return objectMapper.readTree(body).get("id").asLong(); + return SeedFixtures.internalId(jdbcTemplate, "workspaces", UUID.fromString(objectMapper.readTree(body).get("id").asString())); } /** Sudo-mode gate: mint the caller's X-Reauth-Token for the protected call. */ @@ -2167,7 +2165,7 @@ private String reauth(String token) { } private void addMember(long workspaceId, String email, String role) throws Exception { - mockMvc.perform(post("/api/v1/workspaces/" + workspaceId + "/members") + mockMvc.perform(post("/api/v1/workspaces/" + pub("workspaces", workspaceId) + "/members") .header("Authorization", "Bearer " + ownerToken) .header(ReauthTestSupport.HEADER, reauth(ownerToken)) .contentType(MediaType.APPLICATION_JSON) @@ -2230,4 +2228,17 @@ StubDnsResolver stubDnsResolver() { return new StubDnsResolver(); } } + + /** The public identifier of a row this test set up through direct SQL. */ + private UUID pub(String table, long id) { + return SeedFixtures.publicId(jdbcTemplate, table, id); + } + + /** An org row keyed by name, the slug having gone with the sequential ids. */ + private long ensureOrg(String name) { + Long existing = jdbcTemplate.query("select id from orgs where name = ?", + rs -> rs.next() ? rs.getLong(1) : null, name); + return existing != null ? existing : jdbcTemplate.queryForObject( + "insert into orgs (name) values (?) returning id", Long.class, name); + } } diff --git a/src/test/java/kr/ac/pusan/pickle/reference/ReferenceDataTest.java b/src/test/java/kr/ac/pusan/pickle/reference/ReferenceDataTest.java index 732fc0a5..5caba23e 100644 --- a/src/test/java/kr/ac/pusan/pickle/reference/ReferenceDataTest.java +++ b/src/test/java/kr/ac/pusan/pickle/reference/ReferenceDataTest.java @@ -92,22 +92,22 @@ void referenceEndpointsRequireAuthentication() throws Exception { @Test void listsOnlyActiveVisibleOrgsForUsers() throws Exception { - orgRepository.findBySlug("ref-disabled").orElseGet(() -> { - Org disabled = new Org("비활성 기관", "ref-disabled", null); + orgRepository.findFirstByNameOrderByIdAsc("참조 비활성 기관").orElseGet(() -> { + Org disabled = new Org("참조 비활성 기관", null); disabled.setStatus(OrgStatus.DISABLED); return orgRepository.save(disabled); }); - orgRepository.findBySlug("ref-visible").orElseGet(() -> - orgRepository.save(new Org("공개 기관", "ref-visible", null))); + orgRepository.findFirstByNameOrderByIdAsc("공개 기관").orElseGet(() -> + orgRepository.save(new Org("공개 기관", null))); mockMvc.perform(get("/api/v1/orgs").header("Authorization", "Bearer " + accessToken)) .andExpect(status().isOk()) // a plain ACTIVE org is listed as a plain array - .andExpect(jsonPath("$[?(@.slug == 'ref-visible')].name") + .andExpect(jsonPath("$[?(@.name == '공개 기관')].name") .value(org.hamcrest.Matchers.contains("공개 기관"))) // the seed org is hidden: filtered for USER tokens - .andExpect(jsonPath("$[?(@.slug == '" + SeedFixtures.ORG_SLUG + "')]").isEmpty()) - .andExpect(jsonPath("$[?(@.slug == 'ref-disabled')]").isEmpty()); + .andExpect(jsonPath("$[?(@.name == '" + SeedFixtures.ORG_NAME + "')]").isEmpty()) + .andExpect(jsonPath("$[?(@.name == '참조 비활성 기관')]").isEmpty()); } @Test @@ -117,9 +117,9 @@ void managerTierSeesHiddenOrgs() throws Exception { mockMvc.perform(get("/api/v1/orgs").header("Authorization", "Bearer " + managerToken)) .andExpect(status().isOk()) - .andExpect(jsonPath("$[?(@.slug == '" + SeedFixtures.ORG_SLUG + "')].name") + .andExpect(jsonPath("$[?(@.name == '" + SeedFixtures.ORG_NAME + "')].name") .value(org.hamcrest.Matchers.contains(SeedFixtures.ORG_NAME))) - .andExpect(jsonPath("$[?(@.slug == '" + SeedFixtures.ORG_SLUG + "')].hidden") + .andExpect(jsonPath("$[?(@.name == '" + SeedFixtures.ORG_NAME + "')].hidden") .value(org.hamcrest.Matchers.contains(true))); } diff --git a/src/test/java/kr/ac/pusan/pickle/relay/PortForwardingTest.java b/src/test/java/kr/ac/pusan/pickle/relay/PortForwardingTest.java index b27f67e2..4e7f9286 100644 --- a/src/test/java/kr/ac/pusan/pickle/relay/PortForwardingTest.java +++ b/src/test/java/kr/ac/pusan/pickle/relay/PortForwardingTest.java @@ -289,7 +289,7 @@ void applyStateFollowsRelayConfirmationAndErrors() throws Exception { .andExpect(status().isCreated()) .andExpect(jsonPath("$.applyState").value("PENDING")) .andReturn().getResponse().getContentAsString(); - long mappingId = objectMapper.readTree(body).get("id").asLong(); + long mappingId = SeedFixtures.internalId(jdbcTemplate, "port_mappings", UUID.fromString(objectMapper.readTree(body).get("id").asString())); // agent confirms the generation -> ACTIVE jdbcTemplate.update( @@ -311,8 +311,8 @@ void deleteRemovesTheMappingAndBumps() throws Exception { String body = create(vmId, ownerToken, "UDP", 5000) .andExpect(status().isCreated()) .andReturn().getResponse().getContentAsString(); - long mappingId = objectMapper.readTree(body).get("id").asLong(); - mockMvc.perform(delete("/api/v1/vms/" + vmId + "/port-forwardings/" + mappingId) + long mappingId = SeedFixtures.internalId(jdbcTemplate, "port_mappings", UUID.fromString(objectMapper.readTree(body).get("id").asString())); + mockMvc.perform(delete("/api/v1/vms/" + pub("vms", vmId) + "/port-forwardings/" + pub("port_mappings", mappingId)) .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isAccepted()); Long rows = jdbcTemplate.queryForObject( @@ -332,12 +332,12 @@ insert into relays (name, source_ip, port_band_start, port_band_end) """, Long.class, "token-" + UUID.randomUUID().toString().substring(0, 8), sourceIp); - mockMvc.perform(post("/api/v1/admin/relays/" + relayId + "/token") + mockMvc.perform(post("/api/v1/admin/relays/" + pub("relays", relayId) + "/token") .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isForbidden()) .andExpect(jsonPath("$.code").value("REAUTH_REQUIRED")); - String body = mockMvc.perform(post("/api/v1/admin/relays/" + relayId + "/token") + String body = mockMvc.perform(post("/api/v1/admin/relays/" + pub("relays", relayId) + "/token") .header("Authorization", "Bearer " + sysAdminToken) .header(ReauthTestSupport.HEADER, reauth(sysAdminToken))) .andExpect(status().isOk()) @@ -363,7 +363,7 @@ insert into relays (name, source_ip, port_band_start, port_band_end) Long audits = jdbcTemplate.queryForObject(""" select count(*) from audit_logs where action = 'relay.token_issue' and target_id = ? - """, Long.class, relayId); + """, Long.class, pub("relays", relayId).toString()); assertThat(audits).isEqualTo(1); } @@ -375,9 +375,9 @@ void adminSuspendUnsuspendCycleBumpsAndNotifies() throws Exception { long vmId = runningVm(); String body = create(vmId, ownerToken, "TCP", 8080) .andReturn().getResponse().getContentAsString(); - long mappingId = objectMapper.readTree(body).get("id").asLong(); + long mappingId = SeedFixtures.internalId(jdbcTemplate, "port_mappings", UUID.fromString(objectMapper.readTree(body).get("id").asString())); - mockMvc.perform(post("/api/v1/admin/port-mappings/" + mappingId + "/suspend") + mockMvc.perform(post("/api/v1/admin/port-mappings/" + pub("port_mappings", mappingId) + "/suspend") .header("Authorization", "Bearer " + sysAdminToken) .contentType(MediaType.APPLICATION_JSON) .content("{\"reason\":\"약관 위반 신고 확인 중\"}")) @@ -397,13 +397,13 @@ select count(*) from notifications assertThat(ownerNotified).isEqualTo(1); // suspending again is a state conflict - mockMvc.perform(post("/api/v1/admin/port-mappings/" + mappingId + "/suspend") + mockMvc.perform(post("/api/v1/admin/port-mappings/" + pub("port_mappings", mappingId) + "/suspend") .header("Authorization", "Bearer " + sysAdminToken) .contentType(MediaType.APPLICATION_JSON) .content("{\"reason\":\"중복\"}")) .andExpect(status().isConflict()); - mockMvc.perform(post("/api/v1/admin/port-mappings/" + mappingId + "/unsuspend") + mockMvc.perform(post("/api/v1/admin/port-mappings/" + pub("port_mappings", mappingId) + "/unsuspend") .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value("ACTIVE")); @@ -419,9 +419,9 @@ void adminDeleteRemovesBumpsAndNotifiesTheWorkspace() throws Exception { long vmId = runningVm(); String body = create(vmId, ownerToken, "TCP", 8080) .andReturn().getResponse().getContentAsString(); - long mappingId = objectMapper.readTree(body).get("id").asLong(); + long mappingId = SeedFixtures.internalId(jdbcTemplate, "port_mappings", UUID.fromString(objectMapper.readTree(body).get("id").asString())); - mockMvc.perform(delete("/api/v1/admin/port-mappings/" + mappingId) + mockMvc.perform(delete("/api/v1/admin/port-mappings/" + pub("port_mappings", mappingId)) .header("Authorization", "Bearer " + sysAdminToken)) .andExpect(status().isAccepted()); @@ -436,7 +436,7 @@ void adminDeleteRemovesBumpsAndNotifiesTheWorkspace() throws Exception { select count(*) from notifications where user_id = ? and event = 'port_mapping.deleted' and payload ->> 'vmId' = ? - """, Long.class, owner.getId(), String.valueOf(vmId))).isEqualTo(1); + """, Long.class, owner.getId(), pub("vms", vmId).toString())).isEqualTo(1); } @Test @@ -445,10 +445,10 @@ void guardPatchIsTriStatePerField() throws Exception { long vmId = runningVm(); String body = create(vmId, ownerToken, "TCP", 8080) .andReturn().getResponse().getContentAsString(); - long mappingId = objectMapper.readTree(body).get("id").asLong(); + long mappingId = SeedFixtures.internalId(jdbcTemplate, "port_mappings", UUID.fromString(objectMapper.readTree(body).get("id").asString())); // set two guards (0 = disable is a legal stored value) - mockMvc.perform(patch("/api/v1/admin/port-mappings/" + mappingId + "/guards") + mockMvc.perform(patch("/api/v1/admin/port-mappings/" + pub("port_mappings", mappingId) + "/guards") .header("Authorization", "Bearer " + sysAdminToken) .contentType(MediaType.APPLICATION_JSON) .content("{\"ctMax\":256,\"newConnRate\":0}")) @@ -458,7 +458,7 @@ void guardPatchIsTriStatePerField() throws Exception { assertThat(mappingGeneration(relayId)).isEqualTo(2); // omitted field keeps its value; explicit null clears to default - mockMvc.perform(patch("/api/v1/admin/port-mappings/" + mappingId + "/guards") + mockMvc.perform(patch("/api/v1/admin/port-mappings/" + pub("port_mappings", mappingId) + "/guards") .header("Authorization", "Bearer " + sysAdminToken) .contentType(MediaType.APPLICATION_JSON) .content("{\"ctMax\":null}")) @@ -468,12 +468,12 @@ void guardPatchIsTriStatePerField() throws Exception { assertThat(mappingGeneration(relayId)).isEqualTo(3); // negative values and empty bodies are refused - mockMvc.perform(patch("/api/v1/admin/port-mappings/" + mappingId + "/guards") + mockMvc.perform(patch("/api/v1/admin/port-mappings/" + pub("port_mappings", mappingId) + "/guards") .header("Authorization", "Bearer " + sysAdminToken) .contentType(MediaType.APPLICATION_JSON) .content("{\"perSourceRate\":-1}")) .andExpect(status().isUnprocessableEntity()); - mockMvc.perform(patch("/api/v1/admin/port-mappings/" + mappingId + "/guards") + mockMvc.perform(patch("/api/v1/admin/port-mappings/" + pub("port_mappings", mappingId) + "/guards") .header("Authorization", "Bearer " + sysAdminToken) .contentType(MediaType.APPLICATION_JSON) .content("{}")) @@ -486,7 +486,7 @@ void guardPatchRefusesValuesBeyondTheCeiling() throws Exception { long vmId = runningVm(); String body = create(vmId, ownerToken, "TCP", 8080) .andReturn().getResponse().getContentAsString(); - long mappingId = objectMapper.readTree(body).get("id").asLong(); + long mappingId = SeedFixtures.internalId(jdbcTemplate, "port_mappings", UUID.fromString(objectMapper.readTree(body).get("id").asString())); long generationBefore = mappingGeneration(relayId); // A value the relay's packet filter cannot render fails the whole @@ -519,7 +519,7 @@ void guardPatchRefusesABurstWithoutItsRate() throws Exception { long vmId = runningVm(); String body = create(vmId, ownerToken, "TCP", 8080) .andReturn().getResponse().getContentAsString(); - long mappingId = objectMapper.readTree(body).get("id").asLong(); + long mappingId = SeedFixtures.internalId(jdbcTemplate, "port_mappings", UUID.fromString(objectMapper.readTree(body).get("id").asString())); long generationBefore = mappingGeneration(relayId); // A burst without a positive matching rate would make every agent @@ -555,7 +555,7 @@ void guardPatchRefusesABurstWithoutItsRate() throws Exception { } private ResultActions guardPatch(long mappingId, String body) throws Exception { - return mockMvc.perform(patch("/api/v1/admin/port-mappings/" + mappingId + "/guards") + return mockMvc.perform(patch("/api/v1/admin/port-mappings/" + pub("port_mappings", mappingId) + "/guards") .header("Authorization", "Bearer " + sysAdminToken) .contentType(MediaType.APPLICATION_JSON) .content(body)); @@ -571,7 +571,7 @@ void errorVmDeletionRemovesMappingsWithTheIpRelease() throws Exception { long generationBefore = mappingGeneration(relayId); jdbcTemplate.update("update vms set status = 'ERROR'::vm_status where id = ?", vmId); - mockMvc.perform(delete("/api/v1/vms/" + vmId) + mockMvc.perform(delete("/api/v1/vms/" + pub("vms", vmId)) .header("Authorization", "Bearer " + ownerToken) .header(ReauthTestSupport.HEADER, reauth(ownerToken))) .andExpect(status().isAccepted()); @@ -608,14 +608,14 @@ void teardownServiceDemandsACallerTransaction() throws Exception { private ResultActions create(long vmId, String token, String proto, int targetPort) throws Exception { - return mockMvc.perform(post("/api/v1/vms/" + vmId + "/port-forwardings") + return mockMvc.perform(post("/api/v1/vms/" + pub("vms", vmId) + "/port-forwardings") .header("Authorization", "Bearer " + token) .contentType(MediaType.APPLICATION_JSON) .content("{\"proto\":\"" + proto + "\",\"targetPort\":" + targetPort + "}")); } private ResultActions list(long vmId, String token) throws Exception { - return mockMvc.perform(get("/api/v1/vms/" + vmId + "/port-forwardings") + return mockMvc.perform(get("/api/v1/vms/" + pub("vms", vmId) + "/port-forwardings") .header("Authorization", "Bearer " + token)); } @@ -678,11 +678,11 @@ private long createTeam(String slug) throws Exception { "slug", slug)))) .andExpect(status().isCreated()) .andReturn().getResponse().getContentAsString(); - return objectMapper.readTree(body).get("id").asLong(); + return SeedFixtures.internalId(jdbcTemplate, "workspaces", UUID.fromString(objectMapper.readTree(body).get("id").asString())); } private void addMember(long workspaceId, String email, String role) throws Exception { - mockMvc.perform(post("/api/v1/workspaces/" + workspaceId + "/members") + mockMvc.perform(post("/api/v1/workspaces/" + pub("workspaces", workspaceId) + "/members") .header("Authorization", "Bearer " + ownerToken) .header(ReauthTestSupport.HEADER, reauth(ownerToken)) .contentType(MediaType.APPLICATION_JSON) @@ -704,4 +704,9 @@ private User ensureUser(String email, String name) { return userRepository.save(user); }); } + + /** The public identifier of a row this test set up through direct SQL. */ + private UUID pub(String table, long id) { + return SeedFixtures.publicId(jdbcTemplate, table, id); + } } diff --git a/src/test/java/kr/ac/pusan/pickle/relay/RelaySyncEndpointTest.java b/src/test/java/kr/ac/pusan/pickle/relay/RelaySyncEndpointTest.java index e02a99c1..11fca555 100644 --- a/src/test/java/kr/ac/pusan/pickle/relay/RelaySyncEndpointTest.java +++ b/src/test/java/kr/ac/pusan/pickle/relay/RelaySyncEndpointTest.java @@ -247,7 +247,7 @@ void impossibleAppliedGenerationIsAuditedAndNotPersisted() throws Exception { Long audits = jdbcTemplate.queryForObject(""" select count(*) from audit_logs where action = 'relay.sync_violation' and target_id = ? - """, Long.class, relay.id()); + """, Long.class, pub("relays", relay.id()).toString()); assertThat(audits).isEqualTo(1); } @@ -265,7 +265,7 @@ void regressingAppliedGenerationIsAViolation() throws Exception { Long audits = jdbcTemplate.queryForObject(""" select count(*) from audit_logs where action = 'relay.sync_violation' and target_id = ? - """, Long.class, relay.id()); + """, Long.class, pub("relays", relay.id()).toString()); assertThat(audits).isEqualTo(1); } @@ -287,7 +287,7 @@ void aReportOfGenerationZeroIsTreatedAsARestart() throws Exception { Long audits = jdbcTemplate.queryForObject(""" select count(*) from audit_logs where action = 'relay.sync_violation' and target_id = ? - """, Long.class, relay.id()); + """, Long.class, pub("relays", relay.id()).toString()); assertThat(audits).isZero(); } @@ -386,7 +386,7 @@ void insaneCounterReadingsAreDiscardedAndAudited() throws Exception { select count(*) from audit_logs where action = 'relay.sync_violation' and target_id = ? and detail ->> 'kind' = 'counter_sanity' - """, Long.class, relay.id()); + """, Long.class, pub("relays", relay.id()).toString()); assertThat(audits).isEqualTo(1); // a sane follow-up reading baselines normally syncCounters(relay, mappingId, 10, 100, 100); @@ -603,4 +603,9 @@ private static org.springframework.test.web.servlet.request.RequestPostProcessor return request; }; } + + /** The public identifier of a row this test set up through direct SQL. */ + private UUID pub(String table, long id) { + return SeedFixtures.publicId(jdbcTemplate, table, id); + } } 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 144528a0..b52615f8 100644 --- a/src/test/java/kr/ac/pusan/pickle/request/RequestTest.java +++ b/src/test/java/kr/ac/pusan/pickle/request/RequestTest.java @@ -12,6 +12,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.UUID; import kr.ac.pusan.pickle.inventory.NodeRepository; import kr.ac.pusan.pickle.inventory.CatalogStatus; import kr.ac.pusan.pickle.inventory.VmFlavor; @@ -108,7 +109,7 @@ void setUp() { otherMemberToken = jwtService.createAccessToken(otherMember); memberToken = jwtService.createAccessToken(member); outsiderToken = jwtService.createAccessToken(outsider); - org = orgRepository.findBySlug(SeedFixtures.ORG_SLUG).orElseThrow(); + org = orgRepository.findFirstByNameOrderByIdAsc(SeedFixtures.ORG_NAME).orElseThrow(); image = imageRepository.findAll().stream() .filter(t -> t.getName().equals("ubuntu-24.04") && t.getStatus() == CatalogStatus.ACTIVE) .findFirst().orElseThrow(); @@ -133,19 +134,19 @@ void createValidatesRoleImageSpecAndDomains() throws Exception { .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) .andExpect(jsonPath("$.status").value("SUBMITTED")) .andExpect(jsonPath("$.review").value((Object) null)) - .andExpect(jsonPath("$.workspaceId").value(workspaceId)) + .andExpect(jsonPath("$.workspaceId").value(pub("workspaces", workspaceId).toString())) .andExpect(jsonPath("$.workspaceName").isNotEmpty()) .andExpect(jsonPath("$.orgName").isNotEmpty()) - .andExpect(jsonPath("$.requesterId").value(requester.getId())) + .andExpect(jsonPath("$.requesterId").value(requester.getPublicId().toString())) .andExpect(jsonPath("$.requesterName").value("신청자")) - .andExpect(jsonPath("$.vm.imageId").value(image.getId())) - .andExpect(jsonPath("$.vm.flavorId").value(basicFlavor.getId())); + .andExpect(jsonPath("$.vm.imageId").value(image.getPublicId().toString())) + .andExpect(jsonPath("$.vm.flavorId").value(basicFlavor.getPublicId().toString())); // any member may ask — what a request costs is decided at approval, // and reaching the resulting VM is the access list's business postJson("/api/v1/requests", memberToken, validBody(workspaceId)) .andExpect(status().isCreated()) - .andExpect(jsonPath("$.requesterId").value(member.getId())); + .andExpect(jsonPath("$.requesterId").value(member.getPublicId().toString())); // a non-member still cannot submit, and the refusal names the remedy: // joining the workspace, not a rung they are missing @@ -156,14 +157,14 @@ void createValidatesRoleImageSpecAndDomains() throws Exception { containsString("구성원"))); // unknown workspace / org / image → 404 - postJson("/api/v1/requests", requesterToken, with(validBody(workspaceId), "workspaceId", 999_999)) + postJson("/api/v1/requests", requesterToken, with(validBody(workspaceId), "workspaceId", SeedFixtures.UNKNOWN_ID)) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND")); - postJson("/api/v1/requests", requesterToken, with(validBody(workspaceId), "orgId", 999_999)) + postJson("/api/v1/requests", requesterToken, with(validBody(workspaceId), "orgId", SeedFixtures.UNKNOWN_ID)) .andExpect(status().isNotFound()); - postJson("/api/v1/requests", requesterToken, with(validBody(workspaceId), "vm.imageId", 999_999)) + postJson("/api/v1/requests", requesterToken, with(validBody(workspaceId), "vm.imageId", SeedFixtures.UNKNOWN_ID)) .andExpect(status().isNotFound()); - postJson("/api/v1/requests", requesterToken, with(validBody(workspaceId), "vm.flavorId", 999_999)) + postJson("/api/v1/requests", requesterToken, with(validBody(workspaceId), "vm.flavorId", SeedFixtures.UNKNOWN_ID)) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.detail").value("해당 사양 프리셋이 존재하지 않습니다.")); @@ -173,7 +174,7 @@ void createValidatesRoleImageSpecAndDomains() throws Exception { nodeRepository.findAll().getFirst().getId(), 1, 10, CatalogStatus.DISABLED, null)); postJson("/api/v1/requests", requesterToken, - with(validBody(workspaceId), "vm.imageId", disabled.getId())) + with(validBody(workspaceId), "vm.imageId", disabled.getPublicId())) .andExpect(status().isUnprocessableContent()) .andExpect(jsonPath("$.errors[0].field").value("vm.imageId")); @@ -239,7 +240,7 @@ void specReasonBaselineFollowsTheChosenFlavor() throws Exception { // 'small' as-is (1 vCPU / 1024MiB / 10GiB) → 201, no reason needed postJson("/api/v1/requests", requesterToken, bodyFor(workspaceId, smallFlavor)) .andExpect(status().isCreated()) - .andExpect(jsonPath("$.vm.flavorId").value(smallFlavor.getId())) + .andExpect(jsonPath("$.vm.flavorId").value(smallFlavor.getPublicId().toString())) .andExpect(jsonPath("$.vm.reqVcpu").value(1)); // each axis independently: 2 vCPU exceeds 'small' → specReason required @@ -287,7 +288,7 @@ void retiredFlavorIsRejectedAndBothAxesReportIndependently() throws Exception { nodeRepository.findAll().getFirst().getId(), 1, 10, CatalogStatus.DISABLED, null)); postJson("/api/v1/requests", requesterToken, - with(bodyFor(workspaceId, retired), "vm.imageId", retiredImage.getId())) + with(bodyFor(workspaceId, retired), "vm.imageId", retiredImage.getPublicId())) .andExpect(status().isUnprocessableContent()) .andExpect(jsonPath("$.errors.length()").value(2)) .andExpect(jsonPath("$.errors[0].field").value("vm.imageId")) @@ -320,7 +321,7 @@ void emptyOsCatalogListsNothingAndRefusesEverySubmission() throws Exception { // an image that exists but is no longer selectable → 422 on the field postJson("/api/v1/requests", requesterToken, - with(validBody(workspaceId), "vm.imageId", image.getId())) + with(validBody(workspaceId), "vm.imageId", image.getPublicId())) .andExpect(status().isUnprocessableContent()) .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")) .andExpect(jsonPath("$.errors[0].field").value("vm.imageId")) @@ -329,7 +330,7 @@ void emptyOsCatalogListsNothingAndRefusesEverySubmission() throws Exception { // an id that never existed → 404 with a stated reason, not a 500 postJson("/api/v1/requests", requesterToken, - with(validBody(workspaceId), "vm.imageId", 999_999)) + with(validBody(workspaceId), "vm.imageId", SeedFixtures.UNKNOWN_ID)) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND")) .andExpect(jsonPath("$.detail").value("해당 OS 이미지가 존재하지 않습니다.")); @@ -371,8 +372,8 @@ void displayNameIsLengthCappedAndEchoed() throws Exception { .andExpect(status().isCreated()) .andExpect(jsonPath("$.displayName").value("데이터분석 실습 서버")) .andReturn().getResponse().getContentAsString(); - long requestId = objectMapper.readTree(response).get("id").asLong(); - mockMvc.perform(get("/api/v1/requests/" + requestId) + long requestId = SeedFixtures.internalId(jdbcTemplate, "requests", UUID.fromString(objectMapper.readTree(response).get("id").asString())); + mockMvc.perform(get("/api/v1/requests/" + pub("requests", requestId)) .header("Authorization", "Bearer " + requesterToken)) .andExpect(status().isOk()) .andExpect(jsonPath("$.displayName").value("데이터분석 실습 서버")); @@ -392,7 +393,7 @@ void desiredSlugIsValidatedEchoedAndNeverRecycled() throws Exception { .andExpect(status().isCreated()) .andExpect(jsonPath("$.vm.desiredSlug").value("vmr-slug-mine")) .andReturn().getResponse().getContentAsString(); - long mineId = objectMapper.readTree(mine).get("id").asLong(); + long mineId = SeedFixtures.internalId(jdbcTemplate, "requests", UUID.fromString(objectMapper.readTree(mine).get("id").asString())); postJson("/api/v1/requests", requesterToken, validBody(workspaceId)) .andExpect(status().isCreated()) .andExpect(jsonPath("$.vm.desiredSlug").value((Object) null)); @@ -434,7 +435,7 @@ void desiredSlugIsValidatedEchoedAndNeverRecycled() throws Exception { // a rejected request is terminal — its desired slug is submittable again User orgAdmin = userRepository.findByEmail(SeedFixtures.ORGADMIN_EMAIL).orElseThrow(); String orgAdminToken = jwtService.createAccessToken(orgAdmin); - postJson("/api/v1/admin/requests/" + mineId + "/reject", orgAdminToken, + postJson("/api/v1/admin/requests/" + pub("requests", mineId) + "/reject", orgAdminToken, Map.of("comment", "슬러그 재사용 테스트를 위해 반려합니다.")) .andExpect(status().isOk()); postJson("/api/v1/requests", requesterToken, @@ -456,8 +457,8 @@ void listAndDetailVisibilityFollowsMembership() throws Exception { .andExpect(jsonPath("$.page").value(0)) .andExpect(jsonPath("$.size").value(20)) .andExpect(jsonPath("$.totalElements").value(2)) - .andExpect(jsonPath("$.content[0].id").value(second)) - .andExpect(jsonPath("$.content[1].id").value(first)); + .andExpect(jsonPath("$.content[0].id").value(pub("requests", second).toString())) + .andExpect(jsonPath("$.content[1].id").value(pub("requests", first).toString())); // paging envelope: size=1 → two pages mockMvc.perform(get("/api/v1/requests?size=1").header("Authorization", "Bearer " + memberToken)) @@ -474,30 +475,30 @@ void listAndDetailVisibilityFollowsMembership() throws Exception { mockMvc.perform(get("/api/v1/requests").header("Authorization", "Bearer " + outsiderToken)) .andExpect(status().isOk()) .andExpect(jsonPath("$.totalElements").value(0)); - mockMvc.perform(get("/api/v1/requests?workspaceId=" + workspaceId) + mockMvc.perform(get("/api/v1/requests?workspaceId=" + pub("workspaces", workspaceId)) .header("Authorization", "Bearer " + outsiderToken)) .andExpect(status().isForbidden()) .andExpect(jsonPath("$.code").value("ACCESS_DENIED")); // member workspaceId + status filters - mockMvc.perform(get("/api/v1/requests?workspaceId=" + workspaceId + "&status=SUBMITTED") + mockMvc.perform(get("/api/v1/requests?workspaceId=" + pub("workspaces", workspaceId) + "&status=SUBMITTED") .header("Authorization", "Bearer " + memberToken)) .andExpect(status().isOk()) .andExpect(jsonPath("$.totalElements").value(2)); - mockMvc.perform(get("/api/v1/requests?workspaceId=" + workspaceId + "&status=CANCELED") + mockMvc.perform(get("/api/v1/requests?workspaceId=" + pub("workspaces", workspaceId) + "&status=CANCELED") .header("Authorization", "Bearer " + memberToken)) .andExpect(status().isOk()) .andExpect(jsonPath("$.totalElements").value(0)); // detail: participant ok, outsider 403, unknown id 404 - mockMvc.perform(get("/api/v1/requests/" + first).header("Authorization", "Bearer " + memberToken)) + mockMvc.perform(get("/api/v1/requests/" + pub("requests", first)).header("Authorization", "Bearer " + memberToken)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.id").value(first)) + .andExpect(jsonPath("$.id").value(pub("requests", first).toString())) .andExpect(jsonPath("$.review").value((Object) null)); - mockMvc.perform(get("/api/v1/requests/" + first).header("Authorization", "Bearer " + outsiderToken)) + mockMvc.perform(get("/api/v1/requests/" + pub("requests", first)).header("Authorization", "Bearer " + outsiderToken)) .andExpect(status().isForbidden()) .andExpect(jsonPath("$.code").value("ACCESS_DENIED")); - mockMvc.perform(get("/api/v1/requests/999999").header("Authorization", "Bearer " + requesterToken)) + mockMvc.perform(get("/api/v1/requests/" + SeedFixtures.UNKNOWN_ID).header("Authorization", "Bearer " + requesterToken)) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND")); } @@ -512,35 +513,35 @@ void cancelIsForRequesterOrWorkspaceManagersAndOnlyOnce() throws Exception { long second = submit(otherMemberToken, workspaceId); // a fellow member who did not ask, and an outsider, cannot cancel → 403 - postJson("/api/v1/requests/" + first + "/cancel", otherMemberToken, Map.of()) + postJson("/api/v1/requests/" + pub("requests", first) + "/cancel", otherMemberToken, Map.of()) .andExpect(status().isForbidden()) .andExpect(jsonPath("$.code").value("ACCESS_DENIED")); - postJson("/api/v1/requests/" + first + "/cancel", outsiderToken, Map.of()) + postJson("/api/v1/requests/" + pub("requests", first) + "/cancel", outsiderToken, Map.of()) .andExpect(status().isForbidden()); // requester cancels own request → 200 CANCELED - postJson("/api/v1/requests/" + first + "/cancel", requesterToken, Map.of()) + postJson("/api/v1/requests/" + pub("requests", first) + "/cancel", requesterToken, Map.of()) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value("CANCELED")); // second cancel → 409 REQUEST_ALREADY_DECIDED - postJson("/api/v1/requests/" + first + "/cancel", requesterToken, Map.of()) + postJson("/api/v1/requests/" + pub("requests", first) + "/cancel", requesterToken, Map.of()) .andExpect(status().isConflict()) .andExpect(jsonPath("$.code").value("REQUEST_ALREADY_DECIDED")); // the workspace's OWNER may cancel another member's request - postJson("/api/v1/requests/" + second + "/cancel", requesterToken, Map.of()) + postJson("/api/v1/requests/" + pub("requests", second) + "/cancel", requesterToken, Map.of()) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value("CANCELED")); // unknown request → 404 - postJson("/api/v1/requests/999999/cancel", requesterToken, Map.of()) + postJson("/api/v1/requests/" + SeedFixtures.UNKNOWN_ID + "/cancel", requesterToken, Map.of()) .andExpect(status().isNotFound()); // cancellations are audit-logged Long audits = jdbcTemplate.queryForObject( "select count(*) from audit_logs where action = 'request.cancel' and target_id in (?, ?)", - Long.class, first, second); + Long.class, pub("requests", first).toString(), pub("requests", second).toString()); assertThat(audits).isEqualTo(2); } @@ -551,15 +552,15 @@ private Map validBody(long workspaceId) { private Map bodyFor(long workspaceId, VmFlavor flavor) { Map vm = new HashMap<>(); - vm.put("imageId", image.getId()); - vm.put("flavorId", flavor.getId()); + vm.put("imageId", image.getPublicId()); + vm.put("flavorId", flavor.getPublicId()); vm.put("reqVcpu", flavor.getVcpu()); vm.put("reqMemoryMb", flavor.getMemoryMb()); vm.put("reqDiskGb", flavor.getDiskGb()); Map body = new HashMap<>(); body.put("type", "VM"); - body.put("workspaceId", workspaceId); - body.put("orgId", org.getId()); + body.put("workspaceId", pub("workspaces", workspaceId)); + body.put("orgId", org.getPublicId()); body.put("purpose", "수업 실습용 서버"); body.put("vm", vm); return body; @@ -581,7 +582,7 @@ private long submit(String token, long workspaceId) throws Exception { String response = postJson("/api/v1/requests", token, validBody(workspaceId)) .andExpect(status().isCreated()) .andReturn().getResponse().getContentAsString(); - return objectMapper.readTree(response).get("id").asLong(); + return SeedFixtures.internalId(jdbcTemplate, "requests", UUID.fromString(objectMapper.readTree(response).get("id").asString())); } private long createTeam(String token, String slug) throws Exception { @@ -589,7 +590,7 @@ private long createTeam(String token, String slug) throws Exception { Map.of("kind", "TEAM", "name", "테스트 워크스페이스 " + slug)) .andExpect(status().isCreated()) .andReturn().getResponse().getContentAsString(); - return objectMapper.readTree(body).get("id").asLong(); + return SeedFixtures.internalId(jdbcTemplate, "workspaces", UUID.fromString(objectMapper.readTree(body).get("id").asString())); } /** Sudo-mode gate: mint the caller's X-Reauth-Token for the protected call. */ @@ -598,7 +599,7 @@ private String reauth(String token) { } private void addMember(String token, long workspaceId, String email, String role) throws Exception { - mockMvc.perform(post("/api/v1/workspaces/" + workspaceId + "/members") + mockMvc.perform(post("/api/v1/workspaces/" + pub("workspaces", workspaceId) + "/members") .header("Authorization", "Bearer " + token) .header(ReauthTestSupport.HEADER, reauth(token)) .contentType(MediaType.APPLICATION_JSON) @@ -622,4 +623,9 @@ private User ensureUser(String email, String name) { return userRepository.save(user); }); } + + /** The public identifier of a row this test set up through direct SQL. */ + private UUID pub(String table, long id) { + return SeedFixtures.publicId(jdbcTemplate, table, id); + } } diff --git a/src/test/java/kr/ac/pusan/pickle/resource/ResourceIndexTest.java b/src/test/java/kr/ac/pusan/pickle/resource/ResourceIndexTest.java index 1cdde658..da57b732 100644 --- a/src/test/java/kr/ac/pusan/pickle/resource/ResourceIndexTest.java +++ b/src/test/java/kr/ac/pusan/pickle/resource/ResourceIndexTest.java @@ -98,26 +98,26 @@ void inventoryListsTheWorkspaceRowsAMemberMaySee() throws Exception { grantVmToUser(jdbcTemplate, vmId, member.getId(), "VIEWER"); String hostname = hostnameOf(vmId); - mockMvc.perform(get("/api/v1/resources?workspaceId=" + workspaceId) + mockMvc.perform(get("/api/v1/resources?workspaceId=" + pub("workspaces", workspaceId)) .header("Authorization", "Bearer " + memberToken)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.content[?(@.id==" + vmId + ")].type") + .andExpect(jsonPath("$.content[?(@.id==\'" + pub("vms", vmId) + "\')].type") .value(Matchers.contains("VM"))) - .andExpect(jsonPath("$.content[?(@.id==" + vmId + ")].name") + .andExpect(jsonPath("$.content[?(@.id==\'" + pub("vms", vmId) + "\')].name") .value(Matchers.contains(hostname))) - .andExpect(jsonPath("$.content[?(@.id==" + vmId + ")].status") + .andExpect(jsonPath("$.content[?(@.id==\'" + pub("vms", vmId) + "\')].status") .value(Matchers.contains("RUNNING"))) - .andExpect(jsonPath("$.content[?(@.id==" + vmId + ")].workspaceName") + .andExpect(jsonPath("$.content[?(@.id==\'" + pub("vms", vmId) + "\')].workspaceName") .value(Matchers.contains(workspaceName))) - .andExpect(jsonPath("$.content[?(@.id==" + vmId + ")].accessLimited") + .andExpect(jsonPath("$.content[?(@.id==\'" + pub("vms", 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) + mockMvc.perform(get("/api/v1/resources?type=VM&workspaceId=" + pub("workspaces", workspaceId)) .header("Authorization", "Bearer " + memberToken)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.content[?(@.id==" + vmId + ")].name") + .andExpect(jsonPath("$.content[?(@.id==\'" + pub("vms", vmId) + "\')].name") .value(Matchers.contains(hostname))); } @@ -128,14 +128,14 @@ void aWorkspaceTheCallerIsOutsideOfIsAnEmptyPage() throws Exception { // 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) + mockMvc.perform(get("/api/v1/resources?workspaceId=" + pub("workspaces", 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") + mockMvc.perform(get("/api/v1/resources?workspaceId=" + SeedFixtures.UNKNOWN_ID) .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isOk()) .andExpect(jsonPath("$.totalElements").value(0)); @@ -148,22 +148,22 @@ void aRowWithoutAGrantIsRestrictedAndCarriesNoSlug() throws Exception { long vmId = createVm(); String hostname = hostnameOf(vmId); - mockMvc.perform(get("/api/v1/resources?workspaceId=" + workspaceId) + mockMvc.perform(get("/api/v1/resources?workspaceId=" + pub("workspaces", workspaceId)) .header("Authorization", "Bearer " + memberToken)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.content[?(@.id==" + vmId + ")].accessLimited") + .andExpect(jsonPath("$.content[?(@.id==\'" + pub("vms", vmId) + "\')].accessLimited") .value(Matchers.contains(true))) - .andExpect(jsonPath("$.content[?(@.id==" + vmId + ")].ownerNames[0]") + .andExpect(jsonPath("$.content[?(@.id==\'" + pub("vms", 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))); + .andExpect(jsonPath("$.content[?(@.id==\'" + pub("vms", vmId) + "\')].name") + .value(Matchers.contains("VM #" + pub("vms", 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) + String body = mockMvc.perform(get("/api/v1/resources?workspaceId=" + pub("workspaces", workspaceId)) .header("Authorization", "Bearer " + memberToken)) .andReturn().getResponse().getContentAsString(); assertThat(body).doesNotContain(hostname); @@ -171,22 +171,22 @@ void aRowWithoutAGrantIsRestrictedAndCarriesNoSlug() throws Exception { // 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) + mockMvc.perform(get("/api/v1/resources?workspaceId=" + pub("workspaces", workspaceId)) .header("Authorization", "Bearer " + memberToken)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.content[?(@.id==" + vmId + ")].name") + .andExpect(jsonPath("$.content[?(@.id==\'" + pub("vms", 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) + mockMvc.perform(get("/api/v1/resources?workspaceId=" + pub("workspaces", workspaceId)) .header("Authorization", "Bearer " + memberToken)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.content[?(@.id==" + vmId + ")].accessLimited") + .andExpect(jsonPath("$.content[?(@.id==\'" + pub("vms", vmId) + "\')].accessLimited") .value(Matchers.contains(false))) - .andExpect(jsonPath("$.content[?(@.id==" + vmId + ")].name") + .andExpect(jsonPath("$.content[?(@.id==\'" + pub("vms", vmId) + "\')].name") .value(Matchers.contains(hostname))) - .andExpect(jsonPath("$.content[?(@.id==" + vmId + ")].displayName") + .andExpect(jsonPath("$.content[?(@.id==\'" + pub("vms", vmId) + "\')].displayName") .value(Matchers.contains("연구용 서버"))); } @@ -237,11 +237,11 @@ private long createTeam(String slug, String name) throws Exception { Map.of("kind", "TEAM", "name", name)))) .andExpect(status().isCreated()) .andReturn().getResponse().getContentAsString(); - return objectMapper.readTree(body).get("id").asLong(); + return SeedFixtures.internalId(jdbcTemplate, "workspaces", UUID.fromString(objectMapper.readTree(body).get("id").asString())); } private void addMember(long workspaceId, String email) throws Exception { - mockMvc.perform(post("/api/v1/workspaces/" + workspaceId + "/members") + mockMvc.perform(post("/api/v1/workspaces/" + pub("workspaces", workspaceId) + "/members") .header("Authorization", "Bearer " + ownerToken) .header(ReauthTestSupport.HEADER, ReauthTestSupport.seededReauthFor( jdbcTemplate, jwtService, ownerToken)) @@ -259,4 +259,9 @@ private User ensureUser(String email, String name) { return userRepository.save(user); }); } + + /** The public identifier of a row this test set up through direct SQL. */ + private UUID pub(String table, long id) { + return SeedFixtures.publicId(jdbcTemplate, table, id); + } } diff --git a/src/test/java/kr/ac/pusan/pickle/security/ManagerRoleScopingTest.java b/src/test/java/kr/ac/pusan/pickle/security/ManagerRoleScopingTest.java index 4739db21..b48c4900 100644 --- a/src/test/java/kr/ac/pusan/pickle/security/ManagerRoleScopingTest.java +++ b/src/test/java/kr/ac/pusan/pickle/security/ManagerRoleScopingTest.java @@ -70,9 +70,9 @@ class ManagerRoleScopingTest { @BeforeEach void setUp() { - Org orgA = orgRepository.findBySlug(SeedFixtures.ORG_SLUG).orElseThrow(); - orgB = orgRepository.findBySlug("mgr-org-b") - .orElseGet(() -> orgRepository.save(new Org("운영자 테스트 기관 B", "mgr-org-b", null))); + Org orgA = orgRepository.findFirstByNameOrderByIdAsc(SeedFixtures.ORG_NAME).orElseThrow(); + orgB = orgRepository.findFirstByNameOrderByIdAsc("운영자 테스트 기관 B") + .orElseGet(() -> orgRepository.save(new Org("운영자 테스트 기관 B", null))); User orgManagerA = ensureUser("mgr.orgmgr.a@pusan.ac.kr", "기관운영자A", UserRole.ORG_MANAGER, orgA.getId()); @@ -94,41 +94,41 @@ void setUp() { @Test void orgManagerReadsOwnOrgAndMasksForeignOrgAs404() throws Exception { // own-org request is visible; the foreign-org request is masked - get("/api/v1/admin/requests/" + requestInOrgA, orgManagerAToken) + get("/api/v1/admin/requests/" + pub("requests", requestInOrgA), orgManagerAToken) .andExpect(status().isOk()) - .andExpect(jsonPath("$.id").value((int) requestInOrgA)); - get("/api/v1/admin/requests/" + requestInOrgB, orgManagerAToken) + .andExpect(jsonPath("$.id").value(pub("requests", requestInOrgA).toString())); + get("/api/v1/admin/requests/" + pub("requests", requestInOrgB), orgManagerAToken) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND")); - get("/api/v1/admin/requests/" + requestInOrgB + "/context", orgManagerAToken) + get("/api/v1/admin/requests/" + pub("requests", requestInOrgB) + "/context", orgManagerAToken) .andExpect(status().isNotFound()); // deciding a foreign-org request is masked as 404, not 403 (existence privacy) - postJson("/api/v1/admin/requests/" + requestInOrgB + "/reject", orgManagerAToken, + postJson("/api/v1/admin/requests/" + pub("requests", requestInOrgB) + "/reject", orgManagerAToken, Map.of("comment", "타 기관 반려 시도")) .andExpect(status().isNotFound()); // the admin VMs / users / audit surfaces reject a foreign orgId with 404 - get("/api/v1/admin/vms?orgId=" + orgB.getId(), orgManagerAToken) + get("/api/v1/admin/vms?orgId=" + orgB.getPublicId(), orgManagerAToken) .andExpect(status().isNotFound()); - get("/api/v1/admin/users/" + foreignAdminB.getId(), orgManagerAToken) + get("/api/v1/admin/users/" + foreignAdminB.getPublicId(), orgManagerAToken) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND")); - get("/api/v1/admin/audit?orgId=" + orgB.getId(), orgManagerAToken) + get("/api/v1/admin/audit?orgId=" + orgB.getPublicId(), orgManagerAToken) .andExpect(status().isNotFound()); // and it can read its own org's dashboard + queue get("/api/v1/admin/summary", orgManagerAToken).andExpect(status().isOk()); get("/api/v1/admin/requests", orgManagerAToken) .andExpect(status().isOk()) - .andExpect(jsonPath("$.content[?(@.id == %d)]".formatted(requestInOrgA)).exists()) - .andExpect(jsonPath("$.content[?(@.id == %d)]".formatted(requestInOrgB)).doesNotExist()); + .andExpect(jsonPath("$.content[?(@.id == '%s')]".formatted(pub("requests", requestInOrgA))).exists()) + .andExpect(jsonPath("$.content[?(@.id == '%s')]".formatted(pub("requests", requestInOrgB))).doesNotExist()); } @Test void orgManagerMayDecideOwnOrgRequest() throws Exception { // §3.9 †15: approve/reject is the org tier's daily load, granted org-scoped - postJson("/api/v1/admin/requests/" + requestInOrgA + "/reject", orgManagerAToken, + postJson("/api/v1/admin/requests/" + pub("requests", requestInOrgA) + "/reject", orgManagerAToken, Map.of("comment", "기관 자원 여유 부족으로 반려합니다.")) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value("REJECTED")) @@ -147,8 +147,8 @@ void sysManagerReachesEverySysReadSurface() throws Exception { // system-wide queue: sees both orgs' requests (no org pin) get("/api/v1/admin/requests", sysManagerToken) .andExpect(status().isOk()) - .andExpect(jsonPath("$.content[?(@.id == %d)]".formatted(requestInOrgA)).exists()) - .andExpect(jsonPath("$.content[?(@.id == %d)]".formatted(requestInOrgB)).exists()); + .andExpect(jsonPath("$.content[?(@.id == '%s')]".formatted(pub("requests", requestInOrgA))).exists()) + .andExpect(jsonPath("$.content[?(@.id == '%s')]".formatted(pub("requests", requestInOrgB))).exists()); } @Test @@ -156,28 +156,28 @@ void sysManagerIsForbiddenEveryDangerousOp() throws Exception { String future = Instant.now().plus(1, ChronoUnit.DAYS).toString(); // org / role / account / credential mutations postJson("/api/v1/admin/orgs", sysManagerToken, - Map.of("name", "차단 기관", "slug", "sm-deny-" + slug())).andExpect(status().isForbidden()); - patchJson("/api/v1/admin/orgs/1", sysManagerToken, Map.of("name", "x")) + Map.of("name", "차단 기관 " + slug())).andExpect(status().isForbidden()); + patchJson("/api/v1/admin/orgs/" + SeedFixtures.UNKNOWN_ID, sysManagerToken, Map.of("name", "x")) .andExpect(status().isForbidden()); - patchJson("/api/v1/admin/users/1", sysManagerToken, Map.of("role", "USER")) + patchJson("/api/v1/admin/users/" + SeedFixtures.UNKNOWN_ID, sysManagerToken, Map.of("role", "USER")) .andExpect(status().isForbidden()); - postJson("/api/v1/admin/users/1/disable", sysManagerToken, Map.of("reason", "차단")) + postJson("/api/v1/admin/users/" + SeedFixtures.UNKNOWN_ID + "/disable", sysManagerToken, Map.of("reason", "차단")) .andExpect(status().isForbidden()); - post("/api/v1/admin/users/1/enable", sysManagerToken).andExpect(status().isForbidden()); - post("/api/v1/admin/users/1/mfa-reset", sysManagerToken).andExpect(status().isForbidden()); + post("/api/v1/admin/users/" + SeedFixtures.UNKNOWN_ID + "/enable", sysManagerToken).andExpect(status().isForbidden()); + post("/api/v1/admin/users/" + SeedFixtures.UNKNOWN_ID + "/mfa-reset", sysManagerToken).andExpect(status().isForbidden()); // settings kill switch + VM force-delete / deletion lifecycle putJson("/api/v1/admin/settings/ssh_gateway_enabled", sysManagerToken, Map.of("value", false)) .andExpect(status().isForbidden()); - postJson("/api/v1/admin/vms/1/force-delete", sysManagerToken, Map.of("confirmName", "x")) + postJson("/api/v1/admin/vms/" + SeedFixtures.UNKNOWN_ID + "/force-delete", sysManagerToken, Map.of("confirmName", "x")) .andExpect(status().isForbidden()); - postJson("/api/v1/admin/vms/1/schedule-delete", sysManagerToken, + postJson("/api/v1/admin/vms/" + SeedFixtures.UNKNOWN_ID + "/schedule-delete", sysManagerToken, Map.of("scheduledFor", future, "reason", "x")).andExpect(status().isForbidden()); - post("/api/v1/admin/vms/1/cancel-scheduled-delete", sysManagerToken) + post("/api/v1/admin/vms/" + SeedFixtures.UNKNOWN_ID + "/cancel-scheduled-delete", sysManagerToken) .andExpect(status().isForbidden()); // broadcast + approval decisions postJson("/api/v1/admin/announcements", sysManagerToken, Map.of("title", "x", "body", "y", "scope", "ALL")).andExpect(status().isForbidden()); - postJson("/api/v1/admin/requests/" + requestInOrgA + "/reject", sysManagerToken, + postJson("/api/v1/admin/requests/" + pub("requests", requestInOrgA) + "/reject", sysManagerToken, Map.of("comment", "차단")).andExpect(status().isForbidden()); } @@ -189,10 +189,10 @@ void managerTiersGetNoDeleteVmAdminOverride() throws Exception { // closes an audit blind spot: the annotation matrix allows the call, and // only the service-layer OWNER check keeps them out. long vmInOrgB = insertActiveVmInOrg(orgB.getId()); - delete("/api/v1/vms/" + vmInOrgB, orgManagerAToken) + delete("/api/v1/vms/" + pub("vms", vmInOrgB), orgManagerAToken) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND")); - delete("/api/v1/vms/" + vmInOrgB, sysManagerToken) + delete("/api/v1/vms/" + pub("vms", vmInOrgB), sysManagerToken) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND")); } @@ -280,4 +280,9 @@ private User ensureUser(String email, String name, UserRole role, Long orgId) { return userRepository.save(user); }); } + + /** The public identifier of a row this test set up through direct SQL. */ + private UUID pub(String table, long id) { + return SeedFixtures.publicId(jdbcTemplate, table, id); + } } diff --git a/src/test/java/kr/ac/pusan/pickle/security/PublicIdentifierTest.java b/src/test/java/kr/ac/pusan/pickle/security/PublicIdentifierTest.java new file mode 100644 index 00000000..353acb0d --- /dev/null +++ b/src/test/java/kr/ac/pusan/pickle/security/PublicIdentifierTest.java @@ -0,0 +1,206 @@ +package kr.ac.pusan.pickle.security; + +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.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import kr.ac.pusan.pickle.support.AccessGrantFixtures; +import kr.ac.pusan.pickle.support.EmbeddedPostgresConfig; +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.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.jdbc.core.JdbcTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +/** + * The identifier boundary itself: what the API hands out, what it accepts back, + * and that swapping a numeric id in for a public one buys nothing. + * + *

The masking rules have their own suites ({@link VmAccessScopingTest} and + * the per-surface tests). What is asserted here is the property those suites + * assume — that an id which arrives over HTTP is resolved against + * {@code public_id} and against nothing else, so that an internal row number is + * neither a way in nor a thing the API ever says out loud. + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Import(EmbeddedPostgresConfig.class) +class PublicIdentifierTest { + + /** The shape a UUID renders as, which is what every exposed id must match. */ + private static final String UUID_PATTERN = + "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"; + + @Autowired + private MockMvc mockMvc; + @Autowired + private JdbcTemplate jdbcTemplate; + @Autowired + private UserRepository userRepository; + @Autowired + private JwtService jwtService; + @Autowired + private PasswordEncoder passwordEncoder; + + private User owner; + private User outsider; + private String ownerToken; + private String outsiderToken; + private long workspaceId; + private long foreignWorkspaceId; + private long vmId; + private long foreignVmId; + + @BeforeEach + void setUp() { + owner = ensureUser("pubid.owner@pusan.ac.kr", "공개식별자소유자"); + outsider = ensureUser("pubid.outsider@pusan.ac.kr", "공개식별자외부인"); + ownerToken = jwtService.createAccessToken(owner); + outsiderToken = jwtService.createAccessToken(outsider); + workspaceId = ensureWorkspace("공개 식별자 워크스페이스", owner.getId()); + foreignWorkspaceId = ensureWorkspace("공개 식별자 타 워크스페이스", outsider.getId()); + vmId = createVm(workspaceId, "pubid-own"); + foreignVmId = createVm(foreignWorkspaceId, "pubid-foreign"); + AccessGrantFixtures.grantVmToUser(jdbcTemplate, vmId, owner.getId(), "OWNER"); + AccessGrantFixtures.grantVmToUser(jdbcTemplate, foreignVmId, outsider.getId(), "OWNER"); + } + + @Test + void everyIdTheApiHandsBackIsAUuidAndNeverTheRowNumber() throws Exception { + UUID vmPublicId = SeedFixtures.publicId(jdbcTemplate, "vms", vmId); + + mockMvc.perform(get("/api/v1/vms/" + vmPublicId) + .header("Authorization", "Bearer " + ownerToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(vmPublicId.toString())) + .andExpect(jsonPath("$.id").value(org.hamcrest.Matchers.matchesPattern(UUID_PATTERN))) + .andExpect(jsonPath("$.workspaceId") + .value(org.hamcrest.Matchers.matchesPattern(UUID_PATTERN))) + .andExpect(jsonPath("$.orgId") + .value(org.hamcrest.Matchers.matchesPattern(UUID_PATTERN))); + + // The row number is nowhere in the payload — not as the id, not beside it. + String body = mockMvc.perform(get("/api/v1/vms/" + vmPublicId) + .header("Authorization", "Bearer " + ownerToken)) + .andReturn().getResponse().getContentAsString(); + assertThat(body).doesNotContain("\"id\":" + vmId); + } + + @Test + void theRowNumberIsNoLongerAnAddress() throws Exception { + // A well-formed bigint is not a well-formed identifier any more, so the + // path never reaches the VM it used to name: the type conversion refuses + // it first, which is a 422 rather than the old 404. + mockMvc.perform(get("/api/v1/vms/" + vmId) + .header("Authorization", "Bearer " + ownerToken)) + .andExpect(status().isUnprocessableContent()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + } + + @Test + void anIdentifierNoRowHasAnswersTheSame404AnUnknownNumberDid() throws Exception { + mockMvc.perform(get("/api/v1/vms/" + SeedFixtures.UNKNOWN_ID) + .header("Authorization", "Bearer " + ownerToken)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND")); + mockMvc.perform(get("/api/v1/vms/" + SeedFixtures.UNKNOWN_ID + "/events") + .header("Authorization", "Bearer " + ownerToken)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND")); + mockMvc.perform(get("/api/v1/workspaces/" + SeedFixtures.UNKNOWN_ID) + .header("Authorization", "Bearer " + ownerToken)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND")); + } + + @Test + void anotherWorkspacesResourceIsRefusedExactlyAsItsAbsenceWouldBe() throws Exception { + UUID foreignVmPublicId = SeedFixtures.publicId(jdbcTemplate, "vms", foreignVmId); + + // Holding the real identifier of somebody else's VM is worth no more + // than holding one nobody has: both answers are the same 404, so the + // identifier itself discloses nothing about what exists. + var foreign = refusal(foreignVmPublicId); + var unknown = refusal(SeedFixtures.UNKNOWN_ID); + // Everything but the echoed request path, which is the caller's own + // input and tells them nothing they did not already know. + assertThat(foreign).isEqualTo(unknown); + } + + @Test + void theAccessTokenNamesItsAccountByPublicIdAndCarriesNoOrg() { + var claims = jwtService.parse(jwtService.createAccessToken(owner)); + + assertThat(claims.getSubject()).isEqualTo(owner.getPublicId().toString()); + // org_id was written and never read, and it put the org's row number in + // a token any client can decode. + assertThat(claims).doesNotContainKey("org_id"); + } + + /** The refusal a VM read produces, with the echoed request path removed. */ + private String refusal(UUID vmPublicId) throws Exception { + String body = mockMvc.perform(get("/api/v1/vms/" + vmPublicId) + .header("Authorization", "Bearer " + ownerToken)) + .andExpect(status().isNotFound()) + .andReturn().getResponse().getContentAsString(); + return body.replace(vmPublicId.toString(), ""); + } + + private User ensureUser(String email, String name) { + return userRepository.findByEmail(email).orElseGet(() -> { + User user = new User(email, passwordEncoder.encode("Pickle!2345"), name); + user.setStatus(UserStatus.ACTIVE); + return userRepository.save(user); + }); + } + + private long ensureWorkspace(String name, long ownerId) { + List existing = jdbcTemplate.queryForList( + "select id from workspaces where name = ?", Long.class, name); + if (!existing.isEmpty()) { + return existing.getFirst(); + } + long id = jdbcTemplate.queryForObject(""" + insert into workspaces (kind, name) values ('TEAM'::workspace_kind, ?) + returning id + """, Long.class, name); + jdbcTemplate.update(""" + insert into workspace_members (workspace_id, user_id, role) + values (?, ?, 'OWNER'::workspace_member_role) + """, id, ownerId); + return id; + } + + private long createVm(long owningWorkspaceId, String hostname) { + long nodeId = jdbcTemplate.queryForObject("select min(id) from nodes", Long.class); + long imageId = jdbcTemplate.queryForObject("select min(id) from os_images", Long.class); + long orgId = SeedFixtures.seedOrgId(jdbcTemplate); + long requestId = jdbcTemplate.queryForObject(""" + insert into requests (resource_type, workspace_id, org_id, requester_id, purpose) + values ('VM', ?, ?, ?, '공개 식별자 확인') + returning id + """, Long.class, owningWorkspaceId, orgId, owner.getId()); + String unique = hostname + "-" + Instant.now().toEpochMilli(); + return jdbcTemplate.queryForObject(""" + insert into vms (node_id, workspace_id, org_id, request_id, name, hostname, image_id, + vcpu, memory_mb, disk_gb, ssh_username, status) + values (?, ?, ?, ?, ?, ?, ?, 1, 1024, 10, 'ubuntu', 'RUNNING'::vm_status) + returning id + """, Long.class, nodeId, owningWorkspaceId, orgId, requestId, unique, unique, + imageId); + } +} diff --git a/src/test/java/kr/ac/pusan/pickle/security/VmAccessScopingTest.java b/src/test/java/kr/ac/pusan/pickle/security/VmAccessScopingTest.java index 8f320ac3..aea12ce3 100644 --- a/src/test/java/kr/ac/pusan/pickle/security/VmAccessScopingTest.java +++ b/src/test/java/kr/ac/pusan/pickle/security/VmAccessScopingTest.java @@ -206,7 +206,7 @@ private record Requester(long userId, String token) { reauthOp("deleteVm", HttpMethod.DELETE, "/vms/{vmId}", ResourceRole.OWNER, null), op("listVmAccessGrants", HttpMethod.GET, "/vms/{vmId}/access", ResourceRole.OWNER), reauthOp("addVmAccessGrant", HttpMethod.POST, "/vms/{vmId}/access", ResourceRole.OWNER, - "{\"granteeType\":\"USER\",\"userId\":{spareUserId},\"role\":\"VIEWER\"}"), + "{\"granteeType\":\"USER\",\"userId\":\"{spareUserId}\",\"role\":\"VIEWER\"}"), reauthOp("updateVmAccessGrant", HttpMethod.PATCH, "/vms/{vmId}/access/{grantId}", ResourceRole.OWNER, "{\"role\":\"VIEWER\"}"), reauthOp("removeVmAccessGrant", HttpMethod.DELETE, "/vms/{vmId}/access/{grantId}", @@ -345,7 +345,7 @@ void personalAndWorkspaceWideGrantsCombineToTheStrongest() throws Exception { // The personal VIEWER grant alone is below the MEMBER rung startVm needs. MockHttpServletResponse viewerOnly = mockMvc.perform( - MockMvcRequestBuilders.post("/api/v1/vms/" + vmId + "/start") + MockMvcRequestBuilders.post("/api/v1/vms/" + pub("vms", vmId) + "/start") .header("Authorization", "Bearer " + memberToken)) .andReturn().getResponse(); assertThat(viewerOnly.getStatus()).isEqualTo(403); @@ -355,7 +355,7 @@ void personalAndWorkspaceWideGrantsCombineToTheStrongest() throws Exception { // is still there and must not hold them down. grantVmToOwningWorkspace(jdbcTemplate, vmId, "MEMBER"); MockHttpServletResponse combined = mockMvc.perform( - MockMvcRequestBuilders.post("/api/v1/vms/" + vmId + "/start") + MockMvcRequestBuilders.post("/api/v1/vms/" + pub("vms", vmId) + "/start") .header("Authorization", "Bearer " + memberToken)) .andReturn().getResponse(); assertThat(combined.getStatus()).as("the higher of the two grants decides (body: %s)", @@ -422,13 +422,19 @@ private MockHttpServletResponse call(ScopedOp scopedOp, Fixture fixture, Request /** Fills the fixture's ids into a path or body template. */ private String resolve(String template, Fixture fixture) { + // The fixture builds its rows through SQL and holds their internal ids; + // every path placeholder is the public one, because that is the only + // thing the endpoints accept. return template - .replace("{vmId}", String.valueOf(fixture.vmId())) - .replace("{domainId}", String.valueOf(fixture.domainId())) - .replace("{portForwardingId}", String.valueOf(fixture.portForwardingId())) - .replace("{requestId}", String.valueOf(fixture.campusIpRequestId())) - .replace("{grantId}", String.valueOf(fixture.grantId())) - .replace("{spareUserId}", String.valueOf(spareBystander.getId())) + .replace("{vmId}", String.valueOf(pub("vms", fixture.vmId()))) + .replace("{domainId}", String.valueOf(pub("domains", fixture.domainId()))) + .replace("{portForwardingId}", + String.valueOf(pub("port_mappings", fixture.portForwardingId()))) + .replace("{requestId}", + String.valueOf(pub("campus_ip_requests", fixture.campusIpRequestId()))) + .replace("{grantId}", + String.valueOf(pub("resource_access_grants", fixture.grantId()))) + .replace("{spareUserId}", String.valueOf(spareBystander.getPublicId())) .replace("{label}", fixture.label()); } @@ -550,4 +556,9 @@ private User ensureUser(String email, String name) { return userRepository.save(user); }); } + + /** The public identifier of a row this test set up through direct SQL. */ + private UUID pub(String table, long id) { + return SeedFixtures.publicId(jdbcTemplate, table, id); + } } diff --git a/src/test/java/kr/ac/pusan/pickle/seed/DevDataSeederTest.java b/src/test/java/kr/ac/pusan/pickle/seed/DevDataSeederTest.java index e3a4fc4b..b4933e4a 100644 --- a/src/test/java/kr/ac/pusan/pickle/seed/DevDataSeederTest.java +++ b/src/test/java/kr/ac/pusan/pickle/seed/DevDataSeederTest.java @@ -67,7 +67,7 @@ void matchingHashIsNotReencoded() throws Exception { @Test void seedOrgIsNeutralAndHidden() { - Org org = orgRepository.findBySlug(DevDataSeeder.ORG_SLUG).orElseThrow(); + Org org = orgRepository.findFirstByNameOrderByIdAsc(DevDataSeeder.ORG_NAME).orElseThrow(); assertThat(org.getName()).isEqualTo(DevDataSeeder.ORG_NAME); assertThat(org.isHidden()).isTrue(); } diff --git a/src/test/java/kr/ac/pusan/pickle/sshgw/InternalSshGatewayRouteTest.java b/src/test/java/kr/ac/pusan/pickle/sshgw/InternalSshGatewayRouteTest.java index ebdef1b8..ccac98c4 100644 --- a/src/test/java/kr/ac/pusan/pickle/sshgw/InternalSshGatewayRouteTest.java +++ b/src/test/java/kr/ac/pusan/pickle/sshgw/InternalSshGatewayRouteTest.java @@ -118,7 +118,7 @@ void publickeyMemberResolvesWithHostKeysAndIsNotAudited() throws Exception { // NOT bump last_used_at. assertThat(jdbcTemplate.queryForObject( "select count(*) from audit_logs where action = 'sshgw.route' and target_id = ?", - Long.class, vmId)).isZero(); + Long.class, pub("vms", vmId).toString())).isZero(); assertThat(jdbcTemplate.queryForObject( "select last_used_at from user_ssh_keys where fingerprint_sha256 = ?", Instant.class, FP_MEMBER)).isNull(); @@ -230,7 +230,7 @@ void passwordPathDefaultDenyThenOptIn() throws Exception { .andExpect(jsonPath("$.hostKeys[0]").value(HOST_KEY)); assertThat(jdbcTemplate.queryForObject( "select count(*) from audit_logs where action = 'sshgw.route' and target_id = ?", - Long.class, vmId)).isZero(); + Long.class, pub("vms", vmId).toString())).isZero(); } @Test @@ -469,4 +469,9 @@ insert into vms (node_id, workspace_id, org_id, request_id, name, hostname, private static String uniqueSlug() { return "team-route-" + UUID.randomUUID().toString().substring(0, 8); } + + /** The public identifier of a row this test set up through direct SQL. */ + private UUID pub(String table, long id) { + return SeedFixtures.publicId(jdbcTemplate, table, id); + } } diff --git a/src/test/java/kr/ac/pusan/pickle/sshgw/SshGatewaySessionTest.java b/src/test/java/kr/ac/pusan/pickle/sshgw/SshGatewaySessionTest.java index 8b9f9bd8..eaa0e5de 100644 --- a/src/test/java/kr/ac/pusan/pickle/sshgw/SshGatewaySessionTest.java +++ b/src/test/java/kr/ac/pusan/pickle/sshgw/SshGatewaySessionTest.java @@ -99,7 +99,7 @@ void oneOwnerAcrossCandidatesGivesVerifiedAttributionAndBumpsAllKeys() throws Ex Map row = latestSession(); assertThat(((Number) row.get("actor_id")).longValue()).isEqualTo(memberId); - assertThat(((Number) row.get("target_id")).longValue()).isEqualTo(vmId); + assertThat(row.get("target_id")).isEqualTo(pub("vms", vmId).toString()); assertThat(row.get("ip")).isEqualTo(CLIENT_IP); assertThat((String) row.get("detail")).contains(FP_MEMBER).contains(FP_MEMBER2) .contains("\"userId\"").doesNotContain("ambiguous"); @@ -269,4 +269,9 @@ insert into vms (node_id, workspace_id, org_id, request_id, name, hostname, private static String uniqueSlug() { return "team-sess-" + UUID.randomUUID().toString().substring(0, 8); } + + /** The public identifier of a row this test set up through direct SQL. */ + private UUID pub(String table, long id) { + return SeedFixtures.publicId(jdbcTemplate, table, id); + } } diff --git a/src/test/java/kr/ac/pusan/pickle/sshkey/UserSshKeyTest.java b/src/test/java/kr/ac/pusan/pickle/sshkey/UserSshKeyTest.java index d4110571..31f579cf 100644 --- a/src/test/java/kr/ac/pusan/pickle/sshkey/UserSshKeyTest.java +++ b/src/test/java/kr/ac/pusan/pickle/sshkey/UserSshKeyTest.java @@ -11,9 +11,11 @@ import java.time.Instant; import java.util.List; 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.SeedFixtures; import kr.ac.pusan.pickle.user.User; import kr.ac.pusan.pickle.user.UserRepository; import kr.ac.pusan.pickle.user.UserStatus; @@ -45,6 +47,10 @@ class UserSshKeyTest { + "fixture@pickle"; private static final String ED25519_FP = "SHA256:/YMI/y63bR/1ageR+EQuaaCP72ObF73MApQtZH26tW0"; + /** Distinct material: the fingerprint index is global, so reusing a fixture key here would collide. */ + private static final String HIGH_ID_PUB = + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f " + + "highid@pickle"; private static final String ECDSA_PUB = "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBP/TR8FpwtKe" + "2qQyodrbWIUfOV+Tx47Qy1ctZFa/eMnEFVHj8Cl2DHf3a5Ydq9EEGkCTnpQFeXy5lcD6KWCLm0Y= x"; @@ -92,7 +98,7 @@ void registersPastedKeyThenLists() throws Exception { "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICxx5YF5Rp4GZP4rlNsvzVqTXiVyRF/" + "cMyIC9ZMs5ssc")) .andReturn().getResponse().getContentAsString(); - long keyId = objectMapper.readTree(body).get("id").asLong(); + long keyId = SeedFixtures.internalId(jdbcTemplate, "user_ssh_keys", UUID.fromString(objectMapper.readTree(body).get("id").asString())); assertThat(keyId).isPositive(); mockMvc.perform(get("/api/v1/me/ssh-keys").header("Authorization", "Bearer " + ownerToken)) @@ -102,7 +108,7 @@ void registersPastedKeyThenLists() throws Exception { // registration is audited without the key material (fact + fingerprint) List> audits = jdbcTemplate.queryForList( "select detail::text as detail from audit_logs where action = 'user.ssh_key_add' " - + "and target_id = ?", keyId); + + "and target_id = ?", pub("user_ssh_keys", keyId).toString()); assertThat(audits).hasSize(1); assertThat((String) audits.getFirst().get("detail")).contains(ED25519_FP); } @@ -149,7 +155,7 @@ void generatedKeyFingerprintMatchesDownloadedPrivateKey(@org.junit.jupiter.api.i .content(objectMapper.writeValueAsString(Map.of("name", "생성키 조회")))) .andExpect(status().isCreated()) .andReturn().getResponse().getContentAsString(); - long keyId = objectMapper.readTree(body).get("id").asLong(); + long keyId = SeedFixtures.internalId(jdbcTemplate, "user_ssh_keys", UUID.fromString(objectMapper.readTree(body).get("id").asString())); String responseFp = objectMapper.readTree(body).get("fingerprint").asString(); String responsePub = objectMapper.readTree(body).get("publicKey").asString(); String dbFp = jdbcTemplate.queryForObject( @@ -162,7 +168,7 @@ void generatedKeyFingerprintMatchesDownloadedPrivateKey(@org.junit.jupiter.api.i // (2) the KEY the user actually uses = the downloaded private key. Its // public key (what sshpiperd fingerprints) must match what we stored. String pem = objectMapper.readTree(mockMvc.perform( - get("/api/v1/me/ssh-keys/" + keyId + "/private-key") + get("/api/v1/me/ssh-keys/" + pub("user_ssh_keys", keyId) + "/private-key") .header("Authorization", "Bearer " + ownerToken) .header(ReauthTestSupport.HEADER, ownerReauth)) .andExpect(status().isOk()).andReturn().getResponse().getContentAsString()) @@ -208,10 +214,10 @@ void generatesKeyAndRedownloadsPrivateKeyEachAudited() throws Exception { .andExpect(jsonPath("$.algorithm").value("ED25519")) .andExpect(jsonPath("$.privateKeyStored").value(true)) .andReturn().getResponse().getContentAsString(); - long keyId = objectMapper.readTree(body).get("id").asLong(); + long keyId = SeedFixtures.internalId(jdbcTemplate, "user_ssh_keys", UUID.fromString(objectMapper.readTree(body).get("id").asString())); for (int i = 0; i < 2; i++) { - mockMvc.perform(get("/api/v1/me/ssh-keys/" + keyId + "/private-key") + mockMvc.perform(get("/api/v1/me/ssh-keys/" + pub("user_ssh_keys", keyId) + "/private-key") .header("Authorization", "Bearer " + ownerToken) .header(ReauthTestSupport.HEADER, ownerReauth)) .andExpect(status().isOk()) @@ -223,7 +229,7 @@ void generatesKeyAndRedownloadsPrivateKeyEachAudited() throws Exception { // every download audited; never the private key List> downloads = jdbcTemplate.queryForList( "select detail::text as detail from audit_logs where action = 'user.ssh_key_download' " - + "and target_id = ?", keyId); + + "and target_id = ?", pub("user_ssh_keys", keyId).toString()); assertThat(downloads).hasSize(2); assertThat((String) downloads.getFirst().get("detail")).doesNotContain("PRIVATE KEY"); } @@ -232,8 +238,8 @@ void generatesKeyAndRedownloadsPrivateKeyEachAudited() throws Exception { void pastedKeyHasNoDownloadablesPrivateKey() throws Exception { String body = registerKey(ownerToken, "붙여넣기", ED25519_PUB) .andReturn().getResponse().getContentAsString(); - long keyId = objectMapper.readTree(body).get("id").asLong(); - mockMvc.perform(get("/api/v1/me/ssh-keys/" + keyId + "/private-key") + long keyId = SeedFixtures.internalId(jdbcTemplate, "user_ssh_keys", UUID.fromString(objectMapper.readTree(body).get("id").asString())); + mockMvc.perform(get("/api/v1/me/ssh-keys/" + pub("user_ssh_keys", keyId) + "/private-key") .header("Authorization", "Bearer " + ownerToken) .header(ReauthTestSupport.HEADER, ownerReauth)) .andExpect(status().isNotFound()); @@ -243,13 +249,13 @@ void pastedKeyHasNoDownloadablesPrivateKey() throws Exception { void othersKeyIsMaskedAsNotFoundOnDownloadAndDelete() throws Exception { String body = registerKey(ownerToken, "내 키", ED25519_PUB) .andReturn().getResponse().getContentAsString(); - long keyId = objectMapper.readTree(body).get("id").asLong(); + long keyId = SeedFixtures.internalId(jdbcTemplate, "user_ssh_keys", UUID.fromString(objectMapper.readTree(body).get("id").asString())); - mockMvc.perform(get("/api/v1/me/ssh-keys/" + keyId + "/private-key") + mockMvc.perform(get("/api/v1/me/ssh-keys/" + pub("user_ssh_keys", keyId) + "/private-key") .header("Authorization", "Bearer " + otherToken) .header(ReauthTestSupport.HEADER, otherReauth)) .andExpect(status().isNotFound()); - mockMvc.perform(delete("/api/v1/me/ssh-keys/" + keyId) + mockMvc.perform(delete("/api/v1/me/ssh-keys/" + pub("user_ssh_keys", keyId)) .header("Authorization", "Bearer " + otherToken) .header(ReauthTestSupport.HEADER, otherReauth)) .andExpect(status().isNotFound()); @@ -259,9 +265,11 @@ void othersKeyIsMaskedAsNotFoundOnDownloadAndDelete() throws Exception { void deletesOwnKey() throws Exception { String body = registerKey(ownerToken, "삭제할 키", ED25519_PUB) .andReturn().getResponse().getContentAsString(); - long keyId = objectMapper.readTree(body).get("id").asLong(); + long keyId = SeedFixtures.internalId(jdbcTemplate, "user_ssh_keys", UUID.fromString(objectMapper.readTree(body).get("id").asString())); - mockMvc.perform(delete("/api/v1/me/ssh-keys/" + keyId) + // read before the delete: the row it names is gone afterwards + String keyPublicId = pub("user_ssh_keys", keyId).toString(); + mockMvc.perform(delete("/api/v1/me/ssh-keys/" + keyPublicId) .header("Authorization", "Bearer " + ownerToken) .header(ReauthTestSupport.HEADER, ownerReauth)) .andExpect(status().isNoContent()); @@ -270,7 +278,65 @@ void deletesOwnKey() throws Exception { .andExpect(jsonPath("$.length()").value(0)); assertThat(jdbcTemplate.queryForObject( "select count(*) from audit_logs where action = 'user.ssh_key_delete' and target_id = ?", - Long.class, keyId)).isEqualTo(1); + Long.class, keyPublicId)).isEqualTo(1); + } + + /** + * The owner check compares two boxed user ids, and a boxed comparison that + * happens to work is the kind that works only for small numbers: Long caches + * -128..127 and hands back the same instance, so a reference comparison + * succeeds there and nowhere else. Every other test in this file runs on a + * fresh database whose ids start at 1, which is exactly the range that hides + * the fault -- so this one pushes the sequence past the cache first. The dev + * database already had eight keys above that line. + */ + @Test + void ownerCheckHoldsForAUserIdAboveTheBoxedCache() throws Exception { + jdbcTemplate.execute("alter sequence users_id_seq restart with 5000"); + User highId = userRepository.save(highIdUser()); + assertThat(highId.getId()).isGreaterThan(127L); + String token = jwtService.createAccessToken(highId); + String reauth = ReauthTestSupport.seededReauthHeader(jdbcTemplate, highId.getId()); + try { + String body = mockMvc.perform(post("/api/v1/me/ssh-keys") + .header("Authorization", "Bearer " + token) + .header(ReauthTestSupport.HEADER, reauth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(Map.of( + "name", "캐시 밖 키", "publicKey", HIGH_ID_PUB)))) + .andExpect(status().isCreated()) + .andReturn().getResponse().getContentAsString(); + String keyPublicId = objectMapper.readTree(body).get("id").asString(); + + // A pasted key has no stored private key, so the download refuses with + // its own message. What matters here is that it gets past the owner + // check to reach that refusal -- a failed owner check says the key + // does not exist at all. + mockMvc.perform(get("/api/v1/me/ssh-keys/" + keyPublicId + "/private-key") + .header("Authorization", "Bearer " + token) + .header(ReauthTestSupport.HEADER, reauth)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.detail").value( + "다운로드할 개인키가 없습니다. 직접 등록한 키의 개인키는 서버에 보관되지 않습니다.")); + mockMvc.perform(delete("/api/v1/me/ssh-keys/" + keyPublicId) + .header("Authorization", "Bearer " + token) + .header(ReauthTestSupport.HEADER, reauth)) + .andExpect(status().isNoContent()); + } finally { + // This class shares one database with every other test in it, and the + // fingerprint index is global: a key left behind here turns an + // unrelated registration into a 409 further down the run. + jdbcTemplate.update("delete from user_ssh_keys where user_id = ?", highId.getId()); + jdbcTemplate.update("delete from auth_reverifications where user_id = ?", highId.getId()); + userRepository.deleteById(highId.getId()); + } + } + + private User highIdUser() { + User user = new User("sshkey.highid@pusan.ac.kr", "{test-no-login}", "큰아이디"); + user.setStatus(UserStatus.ACTIVE); + user.setEmailVerifiedAt(Instant.now()); + return user; } private org.springframework.test.web.servlet.ResultActions registerKey(String token, @@ -290,4 +356,9 @@ private User ensureUser(String email, String name) { return userRepository.save(user); }); } + + /** The public identifier of a row this test set up through direct SQL. */ + private UUID pub(String table, long id) { + return SeedFixtures.publicId(jdbcTemplate, table, id); + } } diff --git a/src/test/java/kr/ac/pusan/pickle/support/ReauthTestSupport.java b/src/test/java/kr/ac/pusan/pickle/support/ReauthTestSupport.java index d3ab0f79..05d9342c 100644 --- a/src/test/java/kr/ac/pusan/pickle/support/ReauthTestSupport.java +++ b/src/test/java/kr/ac/pusan/pickle/support/ReauthTestSupport.java @@ -63,8 +63,11 @@ public static String seededReauthHeader(JdbcTemplate jdbcTemplate, long userId) */ public static String seededReauthFor(JdbcTemplate jdbcTemplate, JwtService jwtService, String accessToken) { - return seededReauthHeader(jdbcTemplate, - Long.parseLong(jwtService.parse(accessToken).getSubject())); + // The token names its account by public id; the reverification row is + // keyed by the internal one. + return seededReauthHeader(jdbcTemplate, jdbcTemplate.queryForObject( + "select id from users where public_id = ?::uuid", Long.class, + jwtService.parse(accessToken).getSubject())); } /** Seeds a live token pinned to an explicit token_version. */ diff --git a/src/test/java/kr/ac/pusan/pickle/support/SeedFixtures.java b/src/test/java/kr/ac/pusan/pickle/support/SeedFixtures.java index b0b579c2..92e585e3 100644 --- a/src/test/java/kr/ac/pusan/pickle/support/SeedFixtures.java +++ b/src/test/java/kr/ac/pusan/pickle/support/SeedFixtures.java @@ -1,5 +1,6 @@ package kr.ac.pusan.pickle.support; +import java.util.UUID; import kr.ac.pusan.pickle.seed.DevDataSeeder; import org.springframework.jdbc.core.JdbcTemplate; @@ -12,13 +13,10 @@ */ public final class SeedFixtures { - /** Seed org slug ({@link DevDataSeeder#ORG_SLUG}); the org is seeded hidden. */ /** Proxmox template the seeded catalog row clones from. */ public static final int TEMPLATE_VMID = DevDataSeeder.SEED_TEMPLATE_VMID; - public static final String ORG_SLUG = DevDataSeeder.ORG_SLUG; - - /** Seed org display name ({@link DevDataSeeder#ORG_NAME}). */ + /** Seed org display name ({@link DevDataSeeder#ORG_NAME}); the org is seeded hidden. */ public static final String ORG_NAME = DevDataSeeder.ORG_NAME; /** Default {@code pickle.seed.orgadmin-email} (no env override in tests). */ @@ -27,13 +25,45 @@ public final class SeedFixtures { /** Default {@code pickle.seed.sysadmin-email} (no env override in tests). */ public static final String SYSADMIN_EMAIL = "admin@pickle.local"; + /** + * A well-formed identifier no row has, for the "does not exist" probes that + * used to pass a number like 999999. One shared value, so every such probe + * means the same thing wherever it appears. + */ + public static final UUID UNKNOWN_ID = UUID.fromString("00000000-0000-4000-8000-0000deadbeef"); + private SeedFixtures() { } /** Id of the seeded org ({@code queryForObject} throws if the seed is missing). */ public static long seedOrgId(JdbcTemplate jdbcTemplate) { return jdbcTemplate.queryForObject( - "select id from orgs where slug = '" + ORG_SLUG + "'", Long.class); + "select id from orgs where name = '" + ORG_NAME + "'", Long.class); + } + + /** Public identifier of the seeded org. */ + public static UUID seedOrgPublicId(JdbcTemplate jdbcTemplate) { + return publicId(jdbcTemplate, "orgs", seedOrgId(jdbcTemplate)); + } + + /** + * The public identifier of a row a fixture created through direct SQL and + * now has to name over the API — internal ids stay the setup's business, + * public ids are what the endpoints take. {@code table} is always a literal + * from the test, never anything a request supplied. + */ + public static UUID publicId(JdbcTemplate jdbcTemplate, String table, long id) { + return jdbcTemplate.queryForObject( + "select public_id from " + table + " where id = ?", UUID.class, id); + } + + /** + * The internal id behind a public one — for a fixture that created its row + * through the API and now has to set the rest up with direct SQL. + */ + public static long internalId(JdbcTemplate jdbcTemplate, String table, UUID publicId) { + return jdbcTemplate.queryForObject( + "select id from " + table + " where public_id = ?", Long.class, publicId); } /** Id of the seeded ORG_ADMIN account. */ diff --git a/src/test/java/kr/ac/pusan/pickle/terminal/AdminTerminalTest.java b/src/test/java/kr/ac/pusan/pickle/terminal/AdminTerminalTest.java index 962b13f7..b5d51503 100644 --- a/src/test/java/kr/ac/pusan/pickle/terminal/AdminTerminalTest.java +++ b/src/test/java/kr/ac/pusan/pickle/terminal/AdminTerminalTest.java @@ -138,14 +138,14 @@ void terminateIsSysAdminOnly() throws Exception { @Test void terminateRelaysToBridgeAndAuditsWhenKnown() throws Exception { String sessionId = startedSession(orgA); - long vmId = sessionRegistry.get(sessionId).orElseThrow().vmId(); - terminate(sysAdminToken, sessionId).andExpect(status().isNoContent()); bridge.verify(1, postRequestedFor(urlEqualTo("/control/terminate"))); + // The mirror's VM id belongs to no row here, so the session is what + // identifies this audit entry — which is what the detail carries. Long auditActor = jdbcTemplate.queryForObject( "select actor_id from audit_logs where action = 'terminal.force_terminate' " - + "and target_id = ? order by id desc limit 1", Long.class, vmId); + + "and detail ->> 'sessionId' = ?", Long.class, sessionId); assertThat(auditActor).isEqualTo(sysAdmin.getId()); } @@ -179,15 +179,14 @@ private String startedSession(long orgId) { return sessionId; } - private long ensureOrg(String slug) { - Long existing = jdbcTemplate.query("select id from orgs where slug = ?", - rs -> rs.next() ? rs.getLong(1) : null, slug); + private long ensureOrg(String name) { + Long existing = jdbcTemplate.query("select id from orgs where name = ?", + rs -> rs.next() ? rs.getLong(1) : null, name); if (existing != null) { return existing; } return jdbcTemplate.queryForObject( - "insert into orgs (name, slug) values ('터미널관리자B기관', ?) returning id", - Long.class, slug); + "insert into orgs (name) values (?) returning id", Long.class, name); } private User ensureUser(String email, String name, UserRole role, Long orgId) { @@ -200,4 +199,9 @@ private User ensureUser(String email, String name, UserRole role, Long orgId) { return userRepository.save(user); }); } + + /** The public identifier of a row this test set up through direct SQL. */ + private UUID pub(String table, long id) { + return SeedFixtures.publicId(jdbcTemplate, table, id); + } } diff --git a/src/test/java/kr/ac/pusan/pickle/terminal/InternalTerminalTest.java b/src/test/java/kr/ac/pusan/pickle/terminal/InternalTerminalTest.java index 6028d373..342c03ba 100644 --- a/src/test/java/kr/ac/pusan/pickle/terminal/InternalTerminalTest.java +++ b/src/test/java/kr/ac/pusan/pickle/terminal/InternalTerminalTest.java @@ -264,7 +264,7 @@ void sessionEndAuditsCountsOnlyAndIsIdempotent() throws Exception { internal("/internal/terminal/session-end", SSHGW_IP, body).andExpect(status().isNoContent()); assertThat(jdbcTemplate.queryForObject( "select count(*) from audit_logs where action = 'terminal.session_end' " - + "and target_id = ?", Long.class, vmId)).isEqualTo(1L); + + "and target_id = ?", Long.class, pub("vms", vmId).toString().toString())).isEqualTo(1L); } // ── revalidate ──────────────────────────────────────────────────────────── @@ -342,7 +342,7 @@ private MockHttpServletRequestBuilder internalRequest(String path, String peerIp private Set detailKeys(String action, long vmId) { String detail = jdbcTemplate.queryForObject( "select detail::text from audit_logs where action = ? and target_id = ? " - + "order by id desc limit 1", String.class, action, vmId); + + "order by id desc limit 1", String.class, action, pub("vms", vmId).toString()); JsonNode node = objectMapper.readTree(detail); return new TreeSet<>(node.propertyNames()); } @@ -416,4 +416,9 @@ insert into vms (node_id, workspace_id, org_id, request_id, name, hostname, grantVmToUser(jdbcTemplate, vmId, member.getId(), "MEMBER"); return vmId; } + + /** The public identifier of a row this test set up through direct SQL. */ + private UUID pub(String table, long id) { + return SeedFixtures.publicId(jdbcTemplate, table, id); + } } diff --git a/src/test/java/kr/ac/pusan/pickle/terminal/TerminalMintTest.java b/src/test/java/kr/ac/pusan/pickle/terminal/TerminalMintTest.java index c4b48b10..17d5705e 100644 --- a/src/test/java/kr/ac/pusan/pickle/terminal/TerminalMintTest.java +++ b/src/test/java/kr/ac/pusan/pickle/terminal/TerminalMintTest.java @@ -185,7 +185,7 @@ void rateLimitOnUserKeyReturns429() throws Exception { // ── helpers ────────────────────────────────────────────────────────────── private ResultActions mint(String token, long vmId) throws Exception { - return mockMvc.perform(post("/api/v1/vms/" + vmId + "/terminal-sessions") + return mockMvc.perform(post("/api/v1/vms/" + pub("vms", vmId) + "/terminal-sessions") .header("Authorization", "Bearer " + token)); } @@ -237,4 +237,9 @@ private User ensureUser(String email, String name) { return userRepository.save(user); }); } + + /** The public identifier of a row this test set up through direct SQL. */ + private UUID pub(String table, long id) { + return SeedFixtures.publicId(jdbcTemplate, table, id); + } } diff --git a/src/test/java/kr/ac/pusan/pickle/user/AccountLifecycleTest.java b/src/test/java/kr/ac/pusan/pickle/user/AccountLifecycleTest.java index 99165446..6deaf887 100644 --- a/src/test/java/kr/ac/pusan/pickle/user/AccountLifecycleTest.java +++ b/src/test/java/kr/ac/pusan/pickle/user/AccountLifecycleTest.java @@ -124,7 +124,7 @@ void passwordChangeSurvivesCurrentSessionAndKillsOthers() throws Exception { assertThat(jdbcTemplate.queryForObject( "select count(*) from audit_logs where action = 'account.password_change' and target_id = ?", - Long.class, user.getId())).isGreaterThanOrEqualTo(1); + Long.class, user.getPublicId().toString())).isGreaterThanOrEqualTo(1); } @Test @@ -255,8 +255,8 @@ private User createActiveUser(String email, String name) { } private Org ensureOrg() { - return orgRepository.findBySlug("acct-life-org") - .orElseGet(() -> orgRepository.save(new Org("계정수명 테스트 기관", "acct-life-org", null))); + return orgRepository.findFirstByNameOrderByIdAsc("계정수명 테스트 기관") + .orElseGet(() -> orgRepository.save(new Org("계정수명 테스트 기관", null))); } private long personalWorkspaceId(long userId) { 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 d6c81e28..83db0973 100644 --- a/src/test/java/kr/ac/pusan/pickle/vm/VmDeletionTest.java +++ b/src/test/java/kr/ac/pusan/pickle/vm/VmDeletionTest.java @@ -181,12 +181,12 @@ void selfDeleteSchedulesGraceShutdownAndMails() throws Exception { long vmId = createVm(VmStatus.RUNNING); Instant before = Instant.now(); - String body = mockMvc.perform(delete("/api/v1/vms/" + vmId) + String body = mockMvc.perform(delete("/api/v1/vms/" + pub("vms", vmId)) .header("Authorization", "Bearer " + ownerToken) .header(ReauthTestSupport.HEADER, reauth(ownerToken))) .andExpect(status().isAccepted()) .andExpect(jsonPath("$.kind").value("SELF")) - .andExpect(jsonPath("$.requestedById").value(owner.getId())) + .andExpect(jsonPath("$.requestedById").value(owner.getPublicId().toString())) .andExpect(jsonPath("$.reason").value((Object) null)) .andExpect(jsonPath("$.cancelable").value(true)) .andReturn().getResponse().getContentAsString(); @@ -219,7 +219,7 @@ void selfDeleteSchedulesGraceShutdownAndMails() throws Exception { .contains("삭제 취소는 관리자만 가능합니다"); // stacking another deletion on top → 409 - mockMvc.perform(delete("/api/v1/vms/" + vmId) + mockMvc.perform(delete("/api/v1/vms/" + pub("vms", vmId)) .header("Authorization", "Bearer " + ownerToken) .header(ReauthTestSupport.HEADER, reauth(ownerToken))) .andExpect(status().isConflict()) @@ -232,18 +232,18 @@ void selfDeleteAuthorizationAndStateGuards() throws Exception { long vmId = createVm(VmStatus.RUNNING); // MEMBER → 403 (owner-only), non-member → 404 (masked) - mockMvc.perform(delete("/api/v1/vms/" + vmId) + mockMvc.perform(delete("/api/v1/vms/" + pub("vms", vmId)) .header("Authorization", "Bearer " + memberToken) .header(ReauthTestSupport.HEADER, reauth(memberToken))) .andExpect(status().isForbidden()) .andExpect(jsonPath("$.code").value("WORKSPACE_ROLE_INSUFFICIENT")); - mockMvc.perform(delete("/api/v1/vms/" + vmId) + mockMvc.perform(delete("/api/v1/vms/" + pub("vms", vmId)) .header("Authorization", "Bearer " + outsiderToken) .header(ReauthTestSupport.HEADER, reauth(outsiderToken))) .andExpect(status().isNotFound()); // ORG_ADMIN of another org → 404 (existence masked) - mockMvc.perform(delete("/api/v1/vms/" + vmId) + mockMvc.perform(delete("/api/v1/vms/" + pub("vms", vmId)) .header("Authorization", "Bearer " + otherOrgAdminToken()) .header(ReauthTestSupport.HEADER, reauth(otherOrgAdminToken()))) .andExpect(status().isNotFound()); @@ -251,7 +251,7 @@ void selfDeleteAuthorizationAndStateGuards() throws Exception { // state guards: CREATING / NEEDS_ADMIN / DELETED → 409 for (VmStatus status : List.of(VmStatus.CREATING, VmStatus.NEEDS_ADMIN, VmStatus.DELETED)) { setStatus(vmId, status); - mockMvc.perform(delete("/api/v1/vms/" + vmId) + mockMvc.perform(delete("/api/v1/vms/" + pub("vms", vmId)) .header("Authorization", "Bearer " + ownerToken) .header(ReauthTestSupport.HEADER, reauth(ownerToken))) .andExpect(status().isConflict()) @@ -260,7 +260,7 @@ void selfDeleteAuthorizationAndStateGuards() throws Exception { // ORG_ADMIN of the VM's org may delete setStatus(vmId, VmStatus.STOPPED); - mockMvc.perform(delete("/api/v1/vms/" + vmId) + mockMvc.perform(delete("/api/v1/vms/" + pub("vms", vmId)) .header("Authorization", "Bearer " + orgAdminToken) .header(ReauthTestSupport.HEADER, reauth(orgAdminToken))) .andExpect(status().isAccepted()); @@ -288,27 +288,27 @@ void workspaceOwnerStandingCarriesDeletionButNothingInsideTheVm() throws Excepti // rung, so what a workspace owner keeps without a grant is knowing the VM // exists (the listing's limited view), not anything it holds. The 403 // rather than 404 is the difference from a stranger. - mockMvc.perform(MockMvcRequestBuilders.get("/api/v1/vms/" + vmId) + mockMvc.perform(MockMvcRequestBuilders.get("/api/v1/vms/" + pub("vms", vmId)) .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isForbidden()) .andExpect(jsonPath("$.code").value("WORKSPACE_ROLE_INSUFFICIENT")); // Everything inside it is refused. Shutdown is chosen over start so the // VM's state is valid for the op and only the access check can refuse. - mockMvc.perform(post("/api/v1/vms/" + vmId + "/shutdown") + mockMvc.perform(post("/api/v1/vms/" + pub("vms", vmId) + "/shutdown") .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isForbidden()) .andExpect(jsonPath("$.code").value("WORKSPACE_ROLE_INSUFFICIENT")); - mockMvc.perform(post("/api/v1/vms/" + vmId + "/terminal-sessions") + mockMvc.perform(post("/api/v1/vms/" + pub("vms", vmId) + "/terminal-sessions") .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isForbidden()) .andExpect(jsonPath("$.code").value("WORKSPACE_ROLE_INSUFFICIENT")); - mockMvc.perform(MockMvcRequestBuilders.get("/api/v1/vms/" + vmId + "/password") + mockMvc.perform(MockMvcRequestBuilders.get("/api/v1/vms/" + pub("vms", vmId) + "/password") .header("Authorization", "Bearer " + ownerToken) .header(ReauthTestSupport.HEADER, reauth(ownerToken))) .andExpect(status().isForbidden()) .andExpect(jsonPath("$.code").value("WORKSPACE_ROLE_INSUFFICIENT")); - mockMvc.perform(patch("/api/v1/vms/" + vmId + "/settings") + mockMvc.perform(patch("/api/v1/vms/" + pub("vms", vmId) + "/settings") .header("Authorization", "Bearer " + ownerToken) .header(ReauthTestSupport.HEADER, reauth(ownerToken)) .contentType(MediaType.APPLICATION_JSON) @@ -324,43 +324,43 @@ void workspaceOwnerStandingCarriesDeletionButNothingInsideTheVm() throws Excepti mockMvc.perform(MockMvcRequestBuilders.get("/api/v1/vms") .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.content[?(@.id==" + vmId + ")].accessLimited") + .andExpect(jsonPath("$.content[?(@.id==\'" + pub("vms", vmId) + "\')].accessLimited") .value(org.hamcrest.Matchers.contains(true))) - .andExpect(jsonPath("$.content[?(@.id==" + vmId + ")].accessManageAllowed") + .andExpect(jsonPath("$.content[?(@.id==\'" + pub("vms", vmId) + "\')].accessManageAllowed") .value(org.hamcrest.Matchers.contains(true))) - .andExpect(jsonPath("$.content[?(@.id==" + vmId + ")].hostname") + .andExpect(jsonPath("$.content[?(@.id==\'" + pub("vms", vmId) + "\')].hostname") .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))); + .andExpect(jsonPath("$.content[?(@.id==\'" + pub("vms", vmId) + "\')].name") + .value(org.hamcrest.Matchers.contains("VM #" + pub("vms", 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 // and list management outside the rungs. - mockMvc.perform(MockMvcRequestBuilders.get("/api/v1/vms/" + vmId + "/access") + mockMvc.perform(MockMvcRequestBuilders.get("/api/v1/vms/" + pub("vms", vmId) + "/access") .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isOk()); - mockMvc.perform(post("/api/v1/vms/" + vmId + "/access") + mockMvc.perform(post("/api/v1/vms/" + pub("vms", vmId) + "/access") .header("Authorization", "Bearer " + ownerToken) .header(ReauthTestSupport.HEADER, reauth(ownerToken)) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(Map.of( "granteeType", "USER", - "userId", member.getId(), + "userId", member.getPublicId(), "role", "VIEWER")))) .andExpect(status().isCreated()); // A member the list now names, but only as a viewer, still cannot destroy it. - mockMvc.perform(delete("/api/v1/vms/" + vmId) + mockMvc.perform(delete("/api/v1/vms/" + pub("vms", vmId)) .header("Authorization", "Bearer " + memberToken) .header(ReauthTestSupport.HEADER, reauth(memberToken))) .andExpect(status().isForbidden()) .andExpect(jsonPath("$.code").value("WORKSPACE_ROLE_INSUFFICIENT")); // The workspace owner, still named nowhere on the list, may. - mockMvc.perform(delete("/api/v1/vms/" + vmId) + mockMvc.perform(delete("/api/v1/vms/" + pub("vms", vmId)) .header("Authorization", "Bearer " + ownerToken) .header(ReauthTestSupport.HEADER, reauth(ownerToken))) .andExpect(status().isAccepted()); @@ -371,7 +371,7 @@ void errorVmIsDeletedImmediatelyWithIpRelease() throws Exception { long vmId = createVm(VmStatus.ERROR); long allocationId = allocateIp(vmId); - mockMvc.perform(delete("/api/v1/vms/" + vmId) + mockMvc.perform(delete("/api/v1/vms/" + pub("vms", vmId)) .header("Authorization", "Bearer " + ownerToken) .header(ReauthTestSupport.HEADER, reauth(ownerToken))) .andExpect(status().isAccepted()) @@ -401,7 +401,7 @@ void adminScheduleDeleteEnforcesNoticeAndMailsReason() throws Exception { // past instant → 422 with errors[] (the only remaining date rule — // the minimum-notice floor was dropped 2026-07-27) - mockMvc.perform(post("/api/v1/admin/vms/" + vmId + "/schedule-delete") + mockMvc.perform(post("/api/v1/admin/vms/" + pub("vms", vmId) + "/schedule-delete") .header("Authorization", "Bearer " + orgAdminToken) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(Map.of( @@ -413,7 +413,7 @@ void adminScheduleDeleteEnforcesNoticeAndMailsReason() throws Exception { // within the recommended 7-day window is accepted now (warning is // console-side only) long shortNoticeVm = createVm(VmStatus.RUNNING); - mockMvc.perform(post("/api/v1/admin/vms/" + shortNoticeVm + "/schedule-delete") + mockMvc.perform(post("/api/v1/admin/vms/" + pub("vms", shortNoticeVm) + "/schedule-delete") .header("Authorization", "Bearer " + orgAdminToken) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(Map.of( @@ -423,7 +423,7 @@ void adminScheduleDeleteEnforcesNoticeAndMailsReason() throws Exception { .andExpect(jsonPath("$.kind").value("ADMIN")); // blank reason → 422 (bean validation) - mockMvc.perform(post("/api/v1/admin/vms/" + vmId + "/schedule-delete") + mockMvc.perform(post("/api/v1/admin/vms/" + pub("vms", vmId) + "/schedule-delete") .header("Authorization", "Bearer " + orgAdminToken) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(Map.of( @@ -432,7 +432,7 @@ void adminScheduleDeleteEnforcesNoticeAndMailsReason() throws Exception { .andExpect(status().isUnprocessableContent()); Instant scheduledFor = Instant.now().plus(Duration.ofDays(8)); - mockMvc.perform(post("/api/v1/admin/vms/" + vmId + "/schedule-delete") + mockMvc.perform(post("/api/v1/admin/vms/" + pub("vms", vmId) + "/schedule-delete") .header("Authorization", "Bearer " + orgAdminToken) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(Map.of( @@ -454,14 +454,14 @@ void adminScheduleDeleteEnforcesNoticeAndMailsReason() throws Exception { .contains("플랫폼은 VM 데이터를 백업하지 않으며 삭제 후 복구할 수 없습니다"); // double-schedule and regular-user access → 409 / 403 - mockMvc.perform(post("/api/v1/admin/vms/" + vmId + "/schedule-delete") + mockMvc.perform(post("/api/v1/admin/vms/" + pub("vms", vmId) + "/schedule-delete") .header("Authorization", "Bearer " + orgAdminToken) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(Map.of( "scheduledFor", scheduledFor.toString(), "reason", "중복")))) .andExpect(status().isConflict()) .andExpect(jsonPath("$.code").value("VM_INVALID_STATE")); - mockMvc.perform(post("/api/v1/admin/vms/" + vmId + "/schedule-delete") + mockMvc.perform(post("/api/v1/admin/vms/" + pub("vms", vmId) + "/schedule-delete") .header("Authorization", "Bearer " + ownerToken) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(Map.of( @@ -470,7 +470,7 @@ void adminScheduleDeleteEnforcesNoticeAndMailsReason() throws Exception { // self-delete grace in progress (DELETING) can not be re-scheduled long deletingVm = createVm(VmStatus.DELETING); - mockMvc.perform(post("/api/v1/admin/vms/" + deletingVm + "/schedule-delete") + mockMvc.perform(post("/api/v1/admin/vms/" + pub("vms", deletingVm) + "/schedule-delete") .header("Authorization", "Bearer " + orgAdminToken) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(Map.of( @@ -485,7 +485,7 @@ void cancelIsKindAwareAndAdminOnly() throws Exception { // SELF: pending grace → cancel returns the VM to STOPPED long selfVm = createVm(VmStatus.DELETING); markPendingDeletion(selfVm, "SELF", Instant.now().plus(Duration.ofDays(5))); - mockMvc.perform(post("/api/v1/admin/vms/" + selfVm + "/cancel-scheduled-delete") + mockMvc.perform(post("/api/v1/admin/vms/" + pub("vms", selfVm) + "/cancel-scheduled-delete") .header("Authorization", "Bearer " + orgAdminToken)) .andExpect(status().isOk()) .andExpect(jsonPath("$.message").value("삭제가 취소되었습니다.")); @@ -499,7 +499,7 @@ void cancelIsKindAwareAndAdminOnly() throws Exception { // ADMIN: schedule only — the power state is preserved long adminVm = createVm(VmStatus.RUNNING); markPendingDeletion(adminVm, "ADMIN", Instant.now().plus(Duration.ofDays(8))); - mockMvc.perform(post("/api/v1/admin/vms/" + adminVm + "/cancel-scheduled-delete") + mockMvc.perform(post("/api/v1/admin/vms/" + pub("vms", adminVm) + "/cancel-scheduled-delete") .header("Authorization", "Bearer " + orgAdminToken)) .andExpect(status().isOk()); assertThat(statusOf(adminVm)).isEqualTo("RUNNING"); @@ -508,26 +508,26 @@ void cancelIsKindAwareAndAdminOnly() throws Exception { // FORCE / no pending deletion / grace elapsed → 409 long forceVm = createVm(VmStatus.DELETING); markPendingDeletion(forceVm, "FORCE", Instant.now()); - mockMvc.perform(post("/api/v1/admin/vms/" + forceVm + "/cancel-scheduled-delete") + mockMvc.perform(post("/api/v1/admin/vms/" + pub("vms", forceVm) + "/cancel-scheduled-delete") .header("Authorization", "Bearer " + orgAdminToken)) .andExpect(status().isConflict()) .andExpect(jsonPath("$.code").value("VM_INVALID_STATE")); long plainVm = createVm(VmStatus.RUNNING); - mockMvc.perform(post("/api/v1/admin/vms/" + plainVm + "/cancel-scheduled-delete") + mockMvc.perform(post("/api/v1/admin/vms/" + pub("vms", plainVm) + "/cancel-scheduled-delete") .header("Authorization", "Bearer " + orgAdminToken)) .andExpect(status().isConflict()); long elapsedVm = createVm(VmStatus.DELETING); markPendingDeletion(elapsedVm, "SELF", Instant.now().minus(Duration.ofMinutes(1))); - mockMvc.perform(post("/api/v1/admin/vms/" + elapsedVm + "/cancel-scheduled-delete") + mockMvc.perform(post("/api/v1/admin/vms/" + pub("vms", elapsedVm) + "/cancel-scheduled-delete") .header("Authorization", "Bearer " + orgAdminToken)) .andExpect(status().isConflict()); // users cannot cancel (403 via method security), other org → 404 - mockMvc.perform(post("/api/v1/admin/vms/" + adminVm + "/cancel-scheduled-delete") + mockMvc.perform(post("/api/v1/admin/vms/" + pub("vms", adminVm) + "/cancel-scheduled-delete") .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isForbidden()); markPendingDeletion(adminVm, "ADMIN", Instant.now().plus(Duration.ofDays(8))); - mockMvc.perform(post("/api/v1/admin/vms/" + adminVm + "/cancel-scheduled-delete") + mockMvc.perform(post("/api/v1/admin/vms/" + pub("vms", adminVm) + "/cancel-scheduled-delete") .header("Authorization", "Bearer " + otherOrgAdminToken())) .andExpect(status().isNotFound()); } @@ -539,7 +539,7 @@ void adminCancelStaysValidUntilDestructionClaimsTheVm() throws Exception { // alone must not strand an intact VM in an uncancelable state. long dueVm = createVm(VmStatus.RUNNING); markPendingDeletion(dueVm, "ADMIN", Instant.now().minus(Duration.ofMinutes(3))); - mockMvc.perform(post("/api/v1/admin/vms/" + dueVm + "/cancel-scheduled-delete") + mockMvc.perform(post("/api/v1/admin/vms/" + pub("vms", dueVm) + "/cancel-scheduled-delete") .header("Authorization", "Bearer " + orgAdminToken)) .andExpect(status().isOk()); assertThat(statusOf(dueVm)).isEqualTo("RUNNING"); @@ -548,7 +548,7 @@ void adminCancelStaysValidUntilDestructionClaimsTheVm() throws Exception { // Once the destroy pipeline claimed the VM (DELETING), cancel refuses. long claimedVm = createVm(VmStatus.DELETING); markPendingDeletion(claimedVm, "ADMIN", Instant.now().minus(Duration.ofMinutes(3))); - mockMvc.perform(post("/api/v1/admin/vms/" + claimedVm + "/cancel-scheduled-delete") + mockMvc.perform(post("/api/v1/admin/vms/" + pub("vms", claimedVm) + "/cancel-scheduled-delete") .header("Authorization", "Bearer " + orgAdminToken)) .andExpect(status().isConflict()) .andExpect(jsonPath("$.code").value("VM_INVALID_STATE")); @@ -585,21 +585,21 @@ void forceDeleteConfirmsNameAuditsAndEnqueues() throws Exception { String.class, vmId); // ORG_ADMIN → 403 (SYS_ADMIN only) - mockMvc.perform(post("/api/v1/admin/vms/" + vmId + "/force-delete") + mockMvc.perform(post("/api/v1/admin/vms/" + pub("vms", vmId) + "/force-delete") .header("Authorization", "Bearer " + orgAdminToken) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(Map.of("confirmName", vmName)))) .andExpect(status().isForbidden()); // wrong confirm name → 409 VM_CONFIRM_NAME_MISMATCH - mockMvc.perform(post("/api/v1/admin/vms/" + vmId + "/force-delete") + mockMvc.perform(post("/api/v1/admin/vms/" + pub("vms", vmId) + "/force-delete") .header("Authorization", "Bearer " + sysAdminToken) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(Map.of("confirmName", "wrong-name")))) .andExpect(status().isConflict()) .andExpect(jsonPath("$.code").value("VM_CONFIRM_NAME_MISMATCH")); - mockMvc.perform(post("/api/v1/admin/vms/" + vmId + "/force-delete") + mockMvc.perform(post("/api/v1/admin/vms/" + pub("vms", vmId) + "/force-delete") .header("Authorization", "Bearer " + sysAdminToken) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(Map.of("confirmName", vmName)))) @@ -612,7 +612,7 @@ void forceDeleteConfirmsNameAuditsAndEnqueues() throws Exception { assertThat(eventTypes(vmId)).contains("FORCE_DELETE"); Long audits = jdbcTemplate.queryForObject( "select count(*) from audit_logs where action = 'vm.force_delete' and target_id = ?", - Long.class, vmId); + Long.class, pub("vms", vmId).toString()); assertThat(audits).isEqualTo(1); Long enqueued = jdbcTemplate.queryForObject( "select count(*) from jobrunr_jobs where jobsignature like '%DeleteVmJob.deleteVm(%'", @@ -623,7 +623,7 @@ void forceDeleteConfirmsNameAuditsAndEnqueues() throws Exception { // already destroyed → 409 setStatus(vmId, VmStatus.DELETED); - mockMvc.perform(post("/api/v1/admin/vms/" + vmId + "/force-delete") + mockMvc.perform(post("/api/v1/admin/vms/" + pub("vms", vmId) + "/force-delete") .header("Authorization", "Bearer " + sysAdminToken) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(Map.of("confirmName", vmName)))) @@ -641,7 +641,7 @@ insert into provisioning_tasks (vm_id, kind, current_step, status, attempts) String vmName = jdbcTemplate.queryForObject("select name from vms where id = ?", String.class, vmId); - mockMvc.perform(post("/api/v1/admin/vms/" + vmId + "/force-delete") + mockMvc.perform(post("/api/v1/admin/vms/" + pub("vms", vmId) + "/force-delete") .header("Authorization", "Bearer " + sysAdminToken) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(Map.of("confirmName", vmName)))) @@ -863,7 +863,7 @@ private Long deleteJobCount() { private Long auditCount(String action, long vmId) { return jdbcTemplate.queryForObject( "select count(*) from audit_logs where action = ? and target_id = ?", - Long.class, action, vmId); + Long.class, action, pub("vms", vmId).toString()); } private List taskState(long vmId) { @@ -897,12 +897,11 @@ private static String taskStatusPath(String upid) { } private String otherOrgAdminToken() { - Long otherOrgId = jdbcTemplate.query("select id from orgs where slug = 'vmdel-other'", + Long otherOrgId = jdbcTemplate.query("select id from orgs where name = '삭제테스트 타기관'", rs -> rs.next() ? rs.getLong(1) : null); if (otherOrgId == null) { - otherOrgId = jdbcTemplate.queryForObject(""" - insert into orgs (name, slug) values ('삭제테스트 타기관', 'vmdel-other') returning id - """, Long.class); + otherOrgId = jdbcTemplate.queryForObject( + "insert into orgs (name) values ('삭제테스트 타기관') returning id", Long.class); } User otherAdmin = ensureUser("vmdel.otheradmin@pusan.ac.kr", "타기관관리자"); otherAdmin.setRole(UserRole.ORG_ADMIN); @@ -1008,7 +1007,7 @@ void deletePipelineParksWhenDeletionProtectionStillOnAtDestroyTime() throws Exce // enqueued run would silently no-op and the VM would wedge. String vmName = jdbcTemplate.queryForObject("select name from vms where id = ?", String.class, vmId); - mockMvc.perform(post("/api/v1/admin/vms/" + vmId + "/force-delete") + mockMvc.perform(post("/api/v1/admin/vms/" + pub("vms", vmId) + "/force-delete") .header("Authorization", "Bearer " + sysAdminToken) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(Map.of( @@ -1069,12 +1068,12 @@ void deletionProtectionRefusesAllPathsAndOverridePersistsSettingOff() throws Exc setSetting(vmId, "deletion_protection", "true"); // self-delete, admin schedule-delete, and force-delete are all refused - mockMvc.perform(delete("/api/v1/vms/" + vmId) + mockMvc.perform(delete("/api/v1/vms/" + pub("vms", vmId)) .header("Authorization", "Bearer " + ownerToken) .header(ReauthTestSupport.HEADER, reauth(ownerToken))) .andExpect(status().isConflict()) .andExpect(jsonPath("$.code").value("VM_DELETION_PROTECTED")); - mockMvc.perform(post("/api/v1/admin/vms/" + vmId + "/schedule-delete") + mockMvc.perform(post("/api/v1/admin/vms/" + pub("vms", vmId) + "/schedule-delete") .header("Authorization", "Bearer " + sysAdminToken) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(Map.of( @@ -1082,7 +1081,7 @@ void deletionProtectionRefusesAllPathsAndOverridePersistsSettingOff() throws Exc "reason", "정리")))) .andExpect(status().isConflict()) .andExpect(jsonPath("$.code").value("VM_DELETION_PROTECTED")); - mockMvc.perform(post("/api/v1/admin/vms/" + vmId + "/force-delete") + mockMvc.perform(post("/api/v1/admin/vms/" + pub("vms", vmId) + "/force-delete") .header("Authorization", "Bearer " + sysAdminToken) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(Map.of("confirmName", vmName)))) @@ -1092,7 +1091,7 @@ void deletionProtectionRefusesAllPathsAndOverridePersistsSettingOff() throws Exc // Override force-delete persists deletion_protection=false in the same // tx (the destroy pipeline re-checks it — incl. a sweeper-recovered run // after a lost enqueue) and never touches PVE in the request thread. - mockMvc.perform(post("/api/v1/admin/vms/" + vmId + "/force-delete") + mockMvc.perform(post("/api/v1/admin/vms/" + pub("vms", vmId) + "/force-delete") .header("Authorization", "Bearer " + sysAdminToken) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(Map.of( @@ -1106,7 +1105,7 @@ void deletionProtectionRefusesAllPathsAndOverridePersistsSettingOff() throws Exc assertThat(jdbcTemplate.queryForObject(""" select count(*) from audit_logs where action='vm.force_delete' and target_id=? and detail::text like '%overrodeProtection%' - """, Long.class, vmId)).isEqualTo(1L); + """, Long.class, pub("vms", vmId).toString())).isEqualTo(1L); } @Test @@ -1133,12 +1132,12 @@ void stopProtectionGatesMemberPowerOps() throws Exception { long vmId = createVm(VmStatus.RUNNING); setSetting(vmId, "stop_protection", "true"); // MEMBER is blocked from shutdown while stop protection is on - mockMvc.perform(post("/api/v1/vms/" + vmId + "/shutdown") + mockMvc.perform(post("/api/v1/vms/" + pub("vms", vmId) + "/shutdown") .header("Authorization", "Bearer " + memberToken)) .andExpect(status().isConflict()) .andExpect(jsonPath("$.code").value("VM_STOP_PROTECTED")); // OWNER (>= EDITOR) is allowed - mockMvc.perform(post("/api/v1/vms/" + vmId + "/shutdown") + mockMvc.perform(post("/api/v1/vms/" + pub("vms", vmId) + "/shutdown") .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isAccepted()); } @@ -1164,7 +1163,7 @@ private String settingValue(long vmId, String key) { private org.springframework.test.web.servlet.ResultActions patchSetting(String token, long vmId, String key, Object value) throws Exception { return mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders - .patch("/api/v1/vms/" + vmId + "/settings") + .patch("/api/v1/vms/" + pub("vms", vmId) + "/settings") .header("Authorization", "Bearer " + token) .header(ReauthTestSupport.HEADER, reauth(token)) .contentType(MediaType.APPLICATION_JSON) @@ -1216,11 +1215,11 @@ private long createTeam(String slug) throws Exception { Map.of("kind", "TEAM", "name", "삭제 테스트 " + slug)))) .andExpect(status().isCreated()) .andReturn().getResponse().getContentAsString(); - return objectMapper.readTree(body).get("id").asLong(); + return SeedFixtures.internalId(jdbcTemplate, "workspaces", UUID.fromString(objectMapper.readTree(body).get("id").asString())); } private void addMember(long workspaceId, String email, String role) throws Exception { - mockMvc.perform(post("/api/v1/workspaces/" + workspaceId + "/members") + mockMvc.perform(post("/api/v1/workspaces/" + pub("workspaces", workspaceId) + "/members") .header("Authorization", "Bearer " + ownerToken) .header(ReauthTestSupport.HEADER, reauth(ownerToken)) .contentType(MediaType.APPLICATION_JSON) @@ -1236,4 +1235,9 @@ private User ensureUser(String email, String name) { return userRepository.save(user); }); } + + /** The public identifier of a row this test set up through direct SQL. */ + private UUID pub(String table, long id) { + return SeedFixtures.publicId(jdbcTemplate, table, id); + } } diff --git a/src/test/java/kr/ac/pusan/pickle/vm/VmMetricsServiceTest.java b/src/test/java/kr/ac/pusan/pickle/vm/VmMetricsServiceTest.java index af7a772b..6313d42e 100644 --- a/src/test/java/kr/ac/pusan/pickle/vm/VmMetricsServiceTest.java +++ b/src/test/java/kr/ac/pusan/pickle/vm/VmMetricsServiceTest.java @@ -14,6 +14,7 @@ import java.time.ZoneOffset; import java.util.List; import java.util.Optional; +import java.util.UUID; import kr.ac.pusan.pickle.access.ResourceRole; import kr.ac.pusan.pickle.access.VmAccess; import kr.ac.pusan.pickle.access.VmAccessService; @@ -47,7 +48,10 @@ class VmMetricsServiceTest { private static final AuthenticatedUser ACTOR = - new AuthenticatedUser(7L, "metrics@pusan.ac.kr", UserRole.USER, null); + new AuthenticatedUser(7L, UUID.fromString("00000000-0000-4000-8000-000000000007"), + "metrics@pusan.ac.kr", UserRole.USER, null); + + private static final UUID VM_ID = UUID.fromString("00000000-0000-4000-8000-000000000001"); @Mock private VmAccessService vmAccessService; @@ -62,7 +66,7 @@ void aVmWithNoGuestBehindItIsNotAnErrorAndIsNotAskedAbout() { when(vm.getProxmoxVmid()).thenReturn(null); grantVisible(vm); - VmMetricsResponse response = service().metrics(ACTOR, 1L, RrdTimeframe.HOUR); + VmMetricsResponse response = service().metrics(ACTOR, VM_ID, RrdTimeframe.HOUR); assertThat(response.available()).isFalse(); assertThat(response.unavailableReason()).isEqualTo("NOT_PROVISIONED"); @@ -78,7 +82,7 @@ void aDeletedVmAnswersTheSameWayItsGuestIsGone() { when(vm.getStatus()).thenReturn(VmStatus.DELETED); grantVisible(vm); - VmMetricsResponse response = service().metrics(ACTOR, 1L, RrdTimeframe.DAY); + VmMetricsResponse response = service().metrics(ACTOR, VM_ID, RrdTimeframe.DAY); assertThat(response.available()).isFalse(); assertThat(response.unavailableReason()).isEqualTo("NOT_PROVISIONED"); @@ -99,7 +103,7 @@ void rrdRowsBecomePointsAndTheirGapsSurvive() { new VmRrdSample(1_786_335_600L, null, 2.0, null, null, 4.0e9, null, null, null, null))); - VmMetricsResponse response = service().metrics(ACTOR, 1L, RrdTimeframe.HOUR); + VmMetricsResponse response = service().metrics(ACTOR, VM_ID, RrdTimeframe.HOUR); assertThat(response.available()).isTrue(); assertThat(response.unavailableReason()).isNull(); @@ -129,7 +133,7 @@ void aHypervisorThatRefusesIsA503RatherThanAnEmptyChart() { when(proxmoxClient.vmRrdData(anyString(), anyString(), anyInt(), any())) .thenThrow(new ProxmoxApiException(596, "no such resource", "GET rrddata")); - assertThatThrownBy(() -> service().metrics(ACTOR, 1L, RrdTimeframe.HOUR)) + assertThatThrownBy(() -> service().metrics(ACTOR, VM_ID, RrdTimeframe.HOUR)) .isInstanceOfSatisfying(ApiException.class, ex -> { assertThat(ex.getStatus()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); assertThat(ex.getCode()).isEqualTo(ErrorCodes.METRICS_UNAVAILABLE); @@ -147,7 +151,7 @@ void anUnconfiguredApiTokenIsTheSameOutageAsARefusal() { when(proxmoxClient.vmRrdData(anyString(), anyString(), anyInt(), any())) .thenThrow(new IllegalStateException("Proxmox API token is not configured")); - assertThatThrownBy(() -> service().metrics(ACTOR, 1L, RrdTimeframe.HOUR)) + assertThatThrownBy(() -> service().metrics(ACTOR, VM_ID, RrdTimeframe.HOUR)) .isInstanceOfSatisfying(ApiException.class, ex -> { assertThat(ex.getStatus()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); assertThat(ex.getCode()).isEqualTo(ErrorCodes.METRICS_UNAVAILABLE); @@ -167,7 +171,7 @@ void aRowWithoutATimestampIsDroppedRatherThanCharted() { new VmRrdSample(1_786_335_600L, 0.25, 2.0, 2.0e9, 2.0e9, 4.0e9, 1.0, 2.0, 3.0, 4.0))); - VmMetricsResponse response = service().metrics(ACTOR, 1L, RrdTimeframe.HOUR); + VmMetricsResponse response = service().metrics(ACTOR, VM_ID, RrdTimeframe.HOUR); assertThat(response.points()).hasSize(1); assertThat(response.points().getFirst().time()) @@ -183,7 +187,7 @@ private Vm runningVm() { } private void grantVisible(Vm vm) { - when(vmAccessService.of(ACTOR, 1L)) + when(vmAccessService.of(ACTOR, VM_ID)) .thenReturn(new VmAccess(vm, ResourceRole.VIEWER, true, false)); } diff --git a/src/test/java/kr/ac/pusan/pickle/vm/VmPasswordRegenerateTest.java b/src/test/java/kr/ac/pusan/pickle/vm/VmPasswordRegenerateTest.java index ca25a7b0..829f69f9 100644 --- a/src/test/java/kr/ac/pusan/pickle/vm/VmPasswordRegenerateTest.java +++ b/src/test/java/kr/ac/pusan/pickle/vm/VmPasswordRegenerateTest.java @@ -113,7 +113,7 @@ void regeneratesLiveViaGuestAgent() throws Exception { .willReturn(aResponse().withStatus(200) .withHeader("Content-Type", "application/json").withBody("{\"data\":null}"))); - String body = mockMvc.perform(post("/api/v1/vms/" + vmId + "/password/regenerate") + String body = mockMvc.perform(post("/api/v1/vms/" + pub("vms", vmId) + "/password/regenerate") .header("Authorization", "Bearer " + ownerToken) .header(ReauthTestSupport.HEADER, ownerReauth)) .andExpect(status().isOk()) @@ -138,7 +138,7 @@ void regeneratesLiveViaGuestAgent() throws Exception { String detail = jdbcTemplate.queryForObject( "select coalesce(detail::text, '') from audit_logs " + "where action = 'vm.password_regenerate' and target_id = ?", - String.class, vmId); + String.class, pub("vms", vmId).toString()); assertThat(detail).doesNotContain(newPassword); } @@ -153,7 +153,7 @@ void agentUnavailableIsConflict() throws Exception { .withHeader("Content-Type", "application/json") .withBody("{\"data\":null,\"message\":\"QEMU guest agent is not running\"}"))); - mockMvc.perform(post("/api/v1/vms/" + vmId + "/password/regenerate") + mockMvc.perform(post("/api/v1/vms/" + pub("vms", vmId) + "/password/regenerate") .header("Authorization", "Bearer " + ownerToken) .header(ReauthTestSupport.HEADER, ownerReauth)) .andExpect(status().isConflict()) @@ -193,7 +193,7 @@ private long createTeam(String slug) throws Exception { java.util.Map.of("kind", "TEAM", "name", "재생성 " + slug)))) .andExpect(status().isCreated()) .andReturn().getResponse().getContentAsString(); - return objectMapper.readTree(body).get("id").asLong(); + return SeedFixtures.internalId(jdbcTemplate, "workspaces", UUID.fromString(objectMapper.readTree(body).get("id").asString())); } private User ensureUser(String email, String name) { @@ -204,4 +204,9 @@ private User ensureUser(String email, String name) { return userRepository.save(user); }); } + + /** The public identifier of a row this test set up through direct SQL. */ + private UUID pub(String table, long id) { + return SeedFixtures.publicId(jdbcTemplate, table, id); + } } diff --git a/src/test/java/kr/ac/pusan/pickle/vm/VmPasswordTest.java b/src/test/java/kr/ac/pusan/pickle/vm/VmPasswordTest.java index 356836e4..03b84e20 100644 --- a/src/test/java/kr/ac/pusan/pickle/vm/VmPasswordTest.java +++ b/src/test/java/kr/ac/pusan/pickle/vm/VmPasswordTest.java @@ -114,7 +114,7 @@ void setUp() throws Exception { void revealMinRoleRaiseBlocksMember() throws Exception { long vmId = createVm(VmStatus.RUNNING, PASSWORD); // default min-role MEMBER: a MEMBER may reveal - mockMvc.perform(get("/api/v1/vms/" + vmId + "/password") + mockMvc.perform(get("/api/v1/vms/" + pub("vms", vmId) + "/password") .header("Authorization", "Bearer " + memberToken) .header(ReauthTestSupport.HEADER, memberReauth)) .andExpect(status().isOk()); @@ -123,13 +123,13 @@ void revealMinRoleRaiseBlocksMember() throws Exception { insert into vm_settings (vm_id, key, value, updated_at) values (?, 'password_reveal_min_role', '"EDITOR"'::jsonb, now()) """, vmId); - mockMvc.perform(get("/api/v1/vms/" + vmId + "/password") + mockMvc.perform(get("/api/v1/vms/" + pub("vms", vmId) + "/password") .header("Authorization", "Bearer " + memberToken) .header(ReauthTestSupport.HEADER, memberReauth)) .andExpect(status().isForbidden()) .andExpect(jsonPath("$.code").value("WORKSPACE_ROLE_INSUFFICIENT")); // the OWNER still can (OWNER ≥ EDITOR) - mockMvc.perform(get("/api/v1/vms/" + vmId + "/password") + mockMvc.perform(get("/api/v1/vms/" + pub("vms", vmId) + "/password") .header("Authorization", "Bearer " + ownerToken) .header(ReauthTestSupport.HEADER, ownerReauth)) .andExpect(status().isOk()); @@ -139,18 +139,18 @@ insert into vm_settings (vm_id, key, value, updated_at) void regenerateAuthzAndStateGuardsBeforeAgent() throws Exception { // MEMBER/VIEWER are below EDITOR → 403; non-member → 404 long running = createVm(VmStatus.RUNNING, PASSWORD); - mockMvc.perform(post("/api/v1/vms/" + running + "/password/regenerate") + mockMvc.perform(post("/api/v1/vms/" + pub("vms", running) + "/password/regenerate") .header("Authorization", "Bearer " + memberToken) .header(ReauthTestSupport.HEADER, memberReauth)) .andExpect(status().isForbidden()) .andExpect(jsonPath("$.code").value("WORKSPACE_ROLE_INSUFFICIENT")); - mockMvc.perform(post("/api/v1/vms/" + running + "/password/regenerate") + mockMvc.perform(post("/api/v1/vms/" + pub("vms", running) + "/password/regenerate") .header("Authorization", "Bearer " + outsiderToken) .header(ReauthTestSupport.HEADER, outsiderReauth)) .andExpect(status().isNotFound()); // OWNER on a non-RUNNING VM → 409 before any guest-agent call long stopped = createVm(VmStatus.STOPPED, PASSWORD); - mockMvc.perform(post("/api/v1/vms/" + stopped + "/password/regenerate") + mockMvc.perform(post("/api/v1/vms/" + pub("vms", stopped) + "/password/regenerate") .header("Authorization", "Bearer " + ownerToken) .header(ReauthTestSupport.HEADER, ownerReauth)) .andExpect(status().isConflict()) @@ -162,7 +162,7 @@ void revealsRepeatedlyFromCiphertext() throws Exception { long vmId = createVm(VmStatus.RUNNING, PASSWORD); for (int i = 0; i < 2; i++) { - mockMvc.perform(get("/api/v1/vms/" + vmId + "/password") + mockMvc.perform(get("/api/v1/vms/" + pub("vms", vmId) + "/password") .header("Authorization", "Bearer " + ownerToken) .header(ReauthTestSupport.HEADER, ownerReauth)) .andExpect(status().isOk()) @@ -185,7 +185,7 @@ void revealsRepeatedlyFromCiphertext() throws Exception { List> audits = jdbcTemplate.queryForList(""" select action, coalesce(detail::text, '') as detail from audit_logs where action = 'vm.password_reveal' and target_id = ? - """, vmId); + """, pub("vms", vmId).toString()); assertThat(audits).hasSize(2); assertThat((String) audits.getFirst().get("detail")).doesNotContain(PASSWORD); // nothing lands in vm_events either @@ -203,7 +203,7 @@ void stateAndRoleGuards() throws Exception { VmStatus.ERROR, VmStatus.NEEDS_ADMIN)) { jdbcTemplate.update("update vms set status = ?::vm_status where id = ?", status.name(), vmId); - mockMvc.perform(get("/api/v1/vms/" + vmId + "/password") + mockMvc.perform(get("/api/v1/vms/" + pub("vms", vmId) + "/password") .header("Authorization", "Bearer " + ownerToken) .header(ReauthTestSupport.HEADER, ownerReauth)) .andExpect(status().isConflict()) @@ -215,21 +215,21 @@ void stateAndRoleGuards() throws Exception { // VIEWER → 403, non-member → 404 (masked), unauthenticated → 401 jdbcTemplate.update("update vms set status = 'RUNNING' where id = ?", vmId); - mockMvc.perform(get("/api/v1/vms/" + vmId + "/password") + mockMvc.perform(get("/api/v1/vms/" + pub("vms", vmId) + "/password") .header("Authorization", "Bearer " + viewerToken) .header(ReauthTestSupport.HEADER, viewerReauth)) .andExpect(status().isForbidden()) .andExpect(jsonPath("$.code").value("WORKSPACE_ROLE_INSUFFICIENT")); - mockMvc.perform(get("/api/v1/vms/" + vmId + "/password") + mockMvc.perform(get("/api/v1/vms/" + pub("vms", vmId) + "/password") .header("Authorization", "Bearer " + outsiderToken) .header(ReauthTestSupport.HEADER, outsiderReauth)) .andExpect(status().isNotFound()); - mockMvc.perform(get("/api/v1/vms/" + vmId + "/password")) + mockMvc.perform(get("/api/v1/vms/" + pub("vms", vmId) + "/password")) .andExpect(status().isUnauthorized()); // a VM without a stored password (e.g. a mock-provisioned VM) → 410 long mockVm = createVm(VmStatus.RUNNING, null); - mockMvc.perform(get("/api/v1/vms/" + mockVm + "/password") + mockMvc.perform(get("/api/v1/vms/" + pub("vms", mockVm) + "/password") .header("Authorization", "Bearer " + ownerToken) .header(ReauthTestSupport.HEADER, ownerReauth)) .andExpect(status().isGone()) @@ -267,11 +267,11 @@ private long createTeam(String slug) throws Exception { Map.of("kind", "TEAM", "name", "비번 테스트 " + slug)))) .andExpect(status().isCreated()) .andReturn().getResponse().getContentAsString(); - return objectMapper.readTree(body).get("id").asLong(); + return SeedFixtures.internalId(jdbcTemplate, "workspaces", UUID.fromString(objectMapper.readTree(body).get("id").asString())); } private void addMember(long workspaceId, String email, String role) throws Exception { - mockMvc.perform(post("/api/v1/workspaces/" + workspaceId + "/members") + mockMvc.perform(post("/api/v1/workspaces/" + pub("workspaces", workspaceId) + "/members") .header("Authorization", "Bearer " + ownerToken) .header(ReauthTestSupport.HEADER, ownerReauth) .contentType(MediaType.APPLICATION_JSON) @@ -287,4 +287,9 @@ private User ensureUser(String email, String name) { return userRepository.save(user); }); } + + /** The public identifier of a row this test set up through direct SQL. */ + private UUID pub(String table, long id) { + return SeedFixtures.publicId(jdbcTemplate, table, id); + } } diff --git a/src/test/java/kr/ac/pusan/pickle/vm/VmPowerControlTest.java b/src/test/java/kr/ac/pusan/pickle/vm/VmPowerControlTest.java index cb27930a..9c05b0a2 100644 --- a/src/test/java/kr/ac/pusan/pickle/vm/VmPowerControlTest.java +++ b/src/test/java/kr/ac/pusan/pickle/vm/VmPowerControlTest.java @@ -163,7 +163,7 @@ void powerOpsEnforceThePerOpStateMatrix() throws Exception { for (Map.Entry> entry : allowed.entrySet()) { for (VmStatus status : VmStatus.values()) { setStatus(vmId, status); - ResultActions result = mockMvc.perform(post("/api/v1/vms/" + vmId + "/" + entry.getKey()) + ResultActions result = mockMvc.perform(post("/api/v1/vms/" + pub("vms", vmId) + "/" + entry.getKey()) .header("Authorization", "Bearer " + ownerToken)); if (entry.getValue().contains(status)) { result.andExpect(status().isAccepted()) @@ -183,23 +183,23 @@ void powerOpsAuthorizeByTheAccessList() throws Exception { long vmId = createVm(VmStatus.STOPPED); // non-member → 404 (existence masked), VIEWER grant → 403, MEMBER → 202 - mockMvc.perform(post("/api/v1/vms/" + vmId + "/start") + mockMvc.perform(post("/api/v1/vms/" + pub("vms", vmId) + "/start") .header("Authorization", "Bearer " + outsiderToken)) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND")); - mockMvc.perform(post("/api/v1/vms/" + vmId + "/start") + mockMvc.perform(post("/api/v1/vms/" + pub("vms", vmId) + "/start") .header("Authorization", "Bearer " + viewerToken)) .andExpect(status().isForbidden()) .andExpect(jsonPath("$.code").value("WORKSPACE_ROLE_INSUFFICIENT")); - mockMvc.perform(post("/api/v1/vms/" + vmId + "/start") + mockMvc.perform(post("/api/v1/vms/" + pub("vms", vmId) + "/start") .header("Authorization", "Bearer " + memberToken)) .andExpect(status().isAccepted()); // unknown VM → 404; unauthenticated → 401 - mockMvc.perform(post("/api/v1/vms/999999/start") + mockMvc.perform(post("/api/v1/vms/" + SeedFixtures.UNKNOWN_ID + "/start") .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isNotFound()); - mockMvc.perform(post("/api/v1/vms/" + vmId + "/start")) + mockMvc.perform(post("/api/v1/vms/" + pub("vms", vmId) + "/start")) .andExpect(status().isUnauthorized()); } @@ -212,12 +212,12 @@ void startIsUnaffectedByStopProtection() throws Exception { vmId, "stop_protection"); // a MEMBER (below EDITOR) can still START … - mockMvc.perform(post("/api/v1/vms/" + vmId + "/start") + mockMvc.perform(post("/api/v1/vms/" + pub("vms", vmId) + "/start") .header("Authorization", "Bearer " + memberToken)) .andExpect(status().isAccepted()); // … while the same MEMBER is refused a stop-class op by stop protection. setStatus(vmId, VmStatus.RUNNING); - mockMvc.perform(post("/api/v1/vms/" + vmId + "/shutdown") + mockMvc.perform(post("/api/v1/vms/" + pub("vms", vmId) + "/shutdown") .header("Authorization", "Bearer " + memberToken)) .andExpect(status().isConflict()) .andExpect(jsonPath("$.code").value("VM_STOP_PROTECTED")); @@ -226,7 +226,7 @@ void startIsUnaffectedByStopProtection() throws Exception { @Test void rebootAcceptRecordsIntentAndEnqueuesAfterCommit() throws Exception { long vmId = createVm(VmStatus.RUNNING); - mockMvc.perform(post("/api/v1/vms/" + vmId + "/reboot") + mockMvc.perform(post("/api/v1/vms/" + pub("vms", vmId) + "/reboot") .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isAccepted()) .andExpect(jsonPath("$.message").value("VM 재부팅 요청을 접수했습니다. 잠시 후 상태가 갱신됩니다.")); @@ -289,8 +289,9 @@ void staleJobRunSkipsQuietly() { @Test void concurrentDuplicateStartsClaimExactlyOnce() throws Exception { long vmId = createVm(VmStatus.STOPPED); - AuthenticatedUser actor = new AuthenticatedUser(owner.getId(), owner.getEmail(), - owner.getRole(), owner.getOrgId()); + UUID vmPublicId = SeedFixtures.publicId(jdbcTemplate, "vms", vmId); + AuthenticatedUser actor = new AuthenticatedUser(owner.getId(), owner.getPublicId(), + owner.getEmail(), owner.getRole(), owner.getOrgId()); int racers = 4; CountDownLatch start = new CountDownLatch(1); @@ -299,7 +300,7 @@ void concurrentDuplicateStartsClaimExactlyOnce() throws Exception { List> futures = IntStream.range(0, racers) .mapToObj(i -> pool.submit(() -> { start.await(); - return vmLifecycleService.start(actor, vmId); + return vmLifecycleService.start(actor, vmPublicId); })) .toList(); start.countDown(); @@ -431,7 +432,7 @@ private long createTeam(String slug) throws Exception { Map.of("kind", "TEAM", "name", "전원 테스트 " + slug)))) .andExpect(status().isCreated()) .andReturn().getResponse().getContentAsString(); - return objectMapper.readTree(body).get("id").asLong(); + return SeedFixtures.internalId(jdbcTemplate, "workspaces", UUID.fromString(objectMapper.readTree(body).get("id").asString())); } /** Sudo-mode gate: mint the caller's X-Reauth-Token for the protected call. */ @@ -440,7 +441,7 @@ private String reauth(String token) { } private void addMember(long workspaceId, String email, String role) throws Exception { - mockMvc.perform(post("/api/v1/workspaces/" + workspaceId + "/members") + mockMvc.perform(post("/api/v1/workspaces/" + pub("workspaces", workspaceId) + "/members") .header("Authorization", "Bearer " + ownerToken) .header(ReauthTestSupport.HEADER, reauth(ownerToken)) .contentType(org.springframework.http.MediaType.APPLICATION_JSON) @@ -456,4 +457,9 @@ private User ensureUser(String email, String name) { return userRepository.save(user); }); } + + /** The public identifier of a row this test set up through direct SQL. */ + private UUID pub(String table, long id) { + return SeedFixtures.publicId(jdbcTemplate, table, id); + } } diff --git a/src/test/java/kr/ac/pusan/pickle/vm/VmUserSurfaceTest.java b/src/test/java/kr/ac/pusan/pickle/vm/VmUserSurfaceTest.java index 8fc0dc27..5c9631a5 100644 --- a/src/test/java/kr/ac/pusan/pickle/vm/VmUserSurfaceTest.java +++ b/src/test/java/kr/ac/pusan/pickle/vm/VmUserSurfaceTest.java @@ -99,12 +99,12 @@ insert into vm_events (vm_id, type, actor_id, detail) """, vmId, vmId, owner.getId()); // anyone the access list names (VIEWER+) reads, newest first - mockMvc.perform(get("/api/v1/vms/" + vmId + "/events") + mockMvc.perform(get("/api/v1/vms/" + pub("vms", vmId) + "/events") .header("Authorization", "Bearer " + viewerToken)) .andExpect(status().isOk()) .andExpect(jsonPath("$.totalElements").value(2)) .andExpect(jsonPath("$.content[0].type").value("START")) - .andExpect(jsonPath("$.content[0].actorId").value(owner.getId())) + .andExpect(jsonPath("$.content[0].actorId").value(owner.getPublicId().toString())) .andExpect(jsonPath("$.content[0].detail").value((Object) null)) .andExpect(jsonPath("$.content[1].type").value("CREATE")) .andExpect(jsonPath("$.content[1].actorId").value((Object) null)) @@ -112,7 +112,7 @@ insert into vm_events (vm_id, type, actor_id, detail) .andExpect(jsonPath("$.content[1].createdAt").isNotEmpty()); // paging envelope - mockMvc.perform(get("/api/v1/vms/" + vmId + "/events?size=1") + mockMvc.perform(get("/api/v1/vms/" + pub("vms", vmId) + "/events?size=1") .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isOk()) .andExpect(jsonPath("$.content.length()").value(1)) @@ -120,16 +120,16 @@ insert into vm_events (vm_id, type, actor_id, detail) // non-member → 404 (existence masked, v0.3.2), unknown VM → 404, // unauthenticated → 401 - mockMvc.perform(get("/api/v1/vms/" + vmId + "/events") + mockMvc.perform(get("/api/v1/vms/" + pub("vms", vmId) + "/events") .header("Authorization", "Bearer " + outsiderToken)) .andExpect(status().isNotFound()); - mockMvc.perform(get("/api/v1/vms/" + vmId) + mockMvc.perform(get("/api/v1/vms/" + pub("vms", vmId)) .header("Authorization", "Bearer " + outsiderToken)) .andExpect(status().isNotFound()); - mockMvc.perform(get("/api/v1/vms/999999/events") + mockMvc.perform(get("/api/v1/vms/" + SeedFixtures.UNKNOWN_ID + "/events") .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isNotFound()); - mockMvc.perform(get("/api/v1/vms/" + vmId + "/events")) + mockMvc.perform(get("/api/v1/vms/" + pub("vms", vmId) + "/events")) .andExpect(status().isUnauthorized()); } @@ -153,7 +153,7 @@ insert into provisioning_tasks (vm_id, kind, current_step, status, attempts, las where id = ? """, owner.getId(), vmId); - mockMvc.perform(get("/api/v1/vms/" + vmId) + mockMvc.perform(get("/api/v1/vms/" + pub("vms", vmId)) .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isOk()) .andExpect(jsonPath("$.workspaceName").value(workspaceName)) @@ -167,7 +167,7 @@ insert into provisioning_tasks (vm_id, kind, current_step, status, attempts, las .andExpect(jsonPath("$.provisioning.attempts").value(2)) .andExpect(jsonPath("$.provisioning.lastError").value("일시 오류")) .andExpect(jsonPath("$.deletion.kind").value("SELF")) - .andExpect(jsonPath("$.deletion.requestedById").value(owner.getId())) + .andExpect(jsonPath("$.deletion.requestedById").value(owner.getPublicId().toString())) .andExpect(jsonPath("$.deletion.cancelable").value(true)) .andExpect(jsonPath("$.sshUsername").value("ubuntu")) .andExpect(jsonPath("$.updatedAt").isNotEmpty()); @@ -180,7 +180,7 @@ insert into provisioning_tasks (vm_id, kind, current_step, status, attempts, las delete_requested_by = null where id = ? """, vmId); - mockMvc.perform(get("/api/v1/vms/" + vmId) + mockMvc.perform(get("/api/v1/vms/" + pub("vms", vmId)) .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isOk()) .andExpect(jsonPath("$.provisioning").value((Object) null)) @@ -197,7 +197,7 @@ void vmDetailHidesIpReallocatedToAnotherVm() throws Exception { long allocationId = allocateIp(otherVmId); jdbcTemplate.update("update vms set ip_allocation_id = ? where id = ?", allocationId, vmId); - mockMvc.perform(get("/api/v1/vms/" + vmId) + mockMvc.perform(get("/api/v1/vms/" + pub("vms", vmId)) .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isOk()) .andExpect(jsonPath("$.ipAddress").value((Object) null)); @@ -206,35 +206,36 @@ void vmDetailHidesIpReallocatedToAnotherVm() throws Exception { @Test void vmListCarriesWorkspaceName() throws Exception { long vmId = createVm(); - mockMvc.perform(get("/api/v1/vms?workspaceId=" + workspaceId) + mockMvc.perform(get("/api/v1/vms?workspaceId=" + pub("workspaces", workspaceId)) .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.content[0].id").value(vmId)) + .andExpect(jsonPath("$.content[0].id").value(pub("vms", vmId).toString())) .andExpect(jsonPath("$.content[0].workspaceName").value(workspaceName)); } @Test void workspaceDetailExposesMyRoleAndOrgSummaryExposesStatus() throws Exception { - mockMvc.perform(get("/api/v1/workspaces/" + workspaceId) + mockMvc.perform(get("/api/v1/workspaces/" + pub("workspaces", workspaceId)) .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isOk()) .andExpect(jsonPath("$.myRole").value("OWNER")); // myRole is the workspace ladder, which now has only the two rungs that // describe standing in a workspace — this member's read on the VM comes // from the access list and does not show up here. - mockMvc.perform(get("/api/v1/workspaces/" + workspaceId) + mockMvc.perform(get("/api/v1/workspaces/" + pub("workspaces", workspaceId)) .header("Authorization", "Bearer " + viewerToken)) .andExpect(status().isOk()) .andExpect(jsonPath("$.myRole").value("MEMBER")); // USER tokens only see non-hidden ACTIVE orgs, so provide one - jdbcTemplate.update( - "insert into orgs (name, slug) values ('표면 공개 기관', 'vus-visible')" - + " on conflict (slug) do nothing"); + if (jdbcTemplate.queryForObject("select count(*) from orgs where name = ?", + Long.class, "표면 공개 기관") == 0) { + jdbcTemplate.update("insert into orgs (name) values ('표면 공개 기관')"); + } mockMvc.perform(get("/api/v1/orgs") .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isOk()) - .andExpect(jsonPath("$[?(@.slug == 'vus-visible')].status") + .andExpect(jsonPath("$[?(@.name == '표면 공개 기관')].status") .value(org.hamcrest.Matchers.contains("ACTIVE"))); } @@ -281,7 +282,7 @@ private long createTeam(String slug, String name) throws Exception { Map.of("kind", "TEAM", "name", name)))) .andExpect(status().isCreated()) .andReturn().getResponse().getContentAsString(); - return objectMapper.readTree(body).get("id").asLong(); + return SeedFixtures.internalId(jdbcTemplate, "workspaces", UUID.fromString(objectMapper.readTree(body).get("id").asString())); } /** Sudo-mode gate: mint the caller's X-Reauth-Token for the protected call. */ @@ -290,7 +291,7 @@ private String reauth(String token) { } private void addMember(long workspaceId, String email, String role) throws Exception { - mockMvc.perform(post("/api/v1/workspaces/" + workspaceId + "/members") + mockMvc.perform(post("/api/v1/workspaces/" + pub("workspaces", workspaceId) + "/members") .header("Authorization", "Bearer " + ownerToken) .header(ReauthTestSupport.HEADER, reauth(ownerToken)) .contentType(MediaType.APPLICATION_JSON) @@ -306,4 +307,9 @@ private User ensureUser(String email, String name) { return userRepository.save(user); }); } + + /** The public identifier of a row this test set up through direct SQL. */ + private UUID pub(String table, long id) { + return SeedFixtures.publicId(jdbcTemplate, table, id); + } } diff --git a/src/test/java/kr/ac/pusan/pickle/vmsettings/VmSettingsTest.java b/src/test/java/kr/ac/pusan/pickle/vmsettings/VmSettingsTest.java index 059eabfe..40694d98 100644 --- a/src/test/java/kr/ac/pusan/pickle/vmsettings/VmSettingsTest.java +++ b/src/test/java/kr/ac/pusan/pickle/vmsettings/VmSettingsTest.java @@ -100,7 +100,7 @@ void setUp() throws Exception { void readGateAndDefaults() throws Exception { long vmId = createVm(); // EDITOR sees the two-key catalog with defaults - mockMvc.perform(get("/api/v1/vms/" + vmId + "/settings") + mockMvc.perform(get("/api/v1/vms/" + pub("vms", vmId) + "/settings") .header("Authorization", "Bearer " + editorToken)) .andExpect(status().isOk()) .andExpect(jsonPath("$[0].key").value("ssh_password_enabled")) @@ -112,11 +112,11 @@ void readGateAndDefaults() throws Exception { .andExpect(jsonPath("$[1].editable").value(false)); // VIEWER 403, non-member 404 - mockMvc.perform(get("/api/v1/vms/" + vmId + "/settings") + mockMvc.perform(get("/api/v1/vms/" + pub("vms", vmId) + "/settings") .header("Authorization", "Bearer " + viewerToken)) .andExpect(status().isForbidden()) .andExpect(jsonPath("$.code").value("WORKSPACE_ROLE_INSUFFICIENT")); - mockMvc.perform(get("/api/v1/vms/" + vmId + "/settings") + mockMvc.perform(get("/api/v1/vms/" + pub("vms", vmId) + "/settings") .header("Authorization", "Bearer " + outsiderToken)) .andExpect(status().isNotFound()); } @@ -136,7 +136,7 @@ void editorTogglesPasswordSshAndItIsAuditedAndEnforced() throws Exception { // audited old→new, no secrets List> audits = jdbcTemplate.queryForList( "select detail::text as detail from audit_logs where action = 'vm.setting_update' " - + "and target_id = ?", vmId); + + "and target_id = ?", pub("vms", vmId).toString()); assertThat(audits).hasSize(1); assertThat((String) audits.getFirst().get("detail")) .contains("ssh_password_enabled").contains("false").contains("true"); @@ -186,7 +186,7 @@ void validationAndStateGuards() throws Exception { @Test void settingsCatalogCarriesEachKeysRequiredRoleValueTypeAndDefault() throws Exception { long vmId = createVm(); - mockMvc.perform(get("/api/v1/vms/" + vmId + "/settings") + mockMvc.perform(get("/api/v1/vms/" + pub("vms", vmId) + "/settings") .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isOk()) .andExpect(jsonPath("$[?(@.key=='deletion_protection')].value") @@ -209,7 +209,7 @@ void displayNameEditableByEditorWithLengthCapAndClear() throws Exception { assertThat(vmSettingsService.string(vmId, VmSettingsService.DISPLAY_NAME)) .isEqualTo("실습 서버 A"); // VmDetail carries the displayName + orgName join (display fields) - mockMvc.perform(get("/api/v1/vms/" + vmId) + mockMvc.perform(get("/api/v1/vms/" + pub("vms", vmId)) .header("Authorization", "Bearer " + editorToken)) .andExpect(status().isOk()) .andExpect(jsonPath("$.displayName").value("실습 서버 A")) @@ -274,7 +274,7 @@ private String reauth(String token) { private org.springframework.test.web.servlet.ResultActions patchSettings(String token, long vmId, Map settings) throws Exception { - return mockMvc.perform(patch("/api/v1/vms/" + vmId + "/settings") + return mockMvc.perform(patch("/api/v1/vms/" + pub("vms", vmId) + "/settings") .header("Authorization", "Bearer " + token) .header(ReauthTestSupport.HEADER, reauth(token)) .contentType(MediaType.APPLICATION_JSON) @@ -306,11 +306,11 @@ private long createTeam(String slug) throws Exception { Map.of("kind", "TEAM", "name", "설정 테스트 " + slug)))) .andExpect(status().isCreated()) .andReturn().getResponse().getContentAsString(); - return objectMapper.readTree(body).get("id").asLong(); + return SeedFixtures.internalId(jdbcTemplate, "workspaces", UUID.fromString(objectMapper.readTree(body).get("id").asString())); } private void addMember(long workspaceId, String email, String role) throws Exception { - mockMvc.perform(post("/api/v1/workspaces/" + workspaceId + "/members") + mockMvc.perform(post("/api/v1/workspaces/" + pub("workspaces", workspaceId) + "/members") .header("Authorization", "Bearer " + ownerToken) .header(ReauthTestSupport.HEADER, reauth(ownerToken)) .contentType(MediaType.APPLICATION_JSON) @@ -326,4 +326,9 @@ private User ensureUser(String email, String name) { return userRepository.save(user); }); } + + /** The public identifier of a row this test set up through direct SQL. */ + private UUID pub(String table, long id) { + return SeedFixtures.publicId(jdbcTemplate, table, id); + } } 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 6b7be4de..642b9f70 100644 --- a/src/test/java/kr/ac/pusan/pickle/workspace/WorkspaceDeleteTest.java +++ b/src/test/java/kr/ac/pusan/pickle/workspace/WorkspaceDeleteTest.java @@ -85,12 +85,12 @@ void authorizationMatrixAndBlockers() throws Exception { addMember(workspaceId, plainMember.getEmail(), "MEMBER"); // non-member → 404 (existence masked) - mockMvc.perform(delete("/api/v1/workspaces/" + workspaceId) + mockMvc.perform(delete("/api/v1/workspaces/" + pub("workspaces", workspaceId)) .header("Authorization", "Bearer " + outsiderToken)) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND")); // member below OWNER → 403 - mockMvc.perform(delete("/api/v1/workspaces/" + workspaceId) + mockMvc.perform(delete("/api/v1/workspaces/" + pub("workspaces", workspaceId)) .header("Authorization", "Bearer " + plainMemberToken)) .andExpect(status().isForbidden()) .andExpect(jsonPath("$.code").value("WORKSPACE_ROLE_INSUFFICIENT")); @@ -102,7 +102,7 @@ void authorizationMatrixAndBlockers() throws Exception { // 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) + mockMvc.perform(delete("/api/v1/workspaces/" + pub("workspaces", workspaceId)) .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isConflict()) .andExpect(jsonPath("$.code").value("WORKSPACE_HAS_ACTIVE_VMS")); @@ -110,24 +110,24 @@ void authorizationMatrixAndBlockers() throws Exception { 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) + mockMvc.perform(delete("/api/v1/workspaces/" + pub("workspaces", workspaceId)) .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isConflict()) .andExpect(jsonPath("$.code").value("WORKSPACE_HAS_ACTIVE_VMS")); // once destroyed (DELETED), deletion is allowed jdbcTemplate.update("update vms set status = 'DELETED' where id = ?", vmId); - mockMvc.perform(delete("/api/v1/workspaces/" + workspaceId) + mockMvc.perform(delete("/api/v1/workspaces/" + pub("workspaces", workspaceId)) .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isNoContent()); // gone from list and detail (404), audit + member notification recorded - mockMvc.perform(get("/api/v1/workspaces/" + workspaceId) + mockMvc.perform(get("/api/v1/workspaces/" + pub("workspaces", workspaceId)) .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isNotFound()); assertThat(jdbcTemplate.queryForObject( "select count(*) from audit_logs where action='workspace.delete' and target_id=?", - Long.class, workspaceId)).isEqualTo(1L); + Long.class, pub("workspaces", workspaceId).toString())).isEqualTo(1L); // Pinned to this workspace's notification: the owner is shared with the // other cases in this class, which delete workspaces of their own. assertThat(jdbcTemplate.queryForObject( @@ -146,7 +146,7 @@ void personalWorkspaceIsUndeletable() throws Exception { select g.id from workspaces g join workspace_members gm on gm.workspace_id = g.id where gm.user_id = ? and g.kind = 'PERSONAL' """, Long.class, owner.getId()); - mockMvc.perform(delete("/api/v1/workspaces/" + personalId) + mockMvc.perform(delete("/api/v1/workspaces/" + pub("workspaces", personalId)) .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isConflict()) .andExpect(jsonPath("$.code").value("WORKSPACE_PERSONAL_UNDELETABLE")); @@ -159,7 +159,7 @@ void deletingWorkspaceCancelsSubmittedRequestsAndApprovalThenConflicts() throws String sysAdminToken = jwtService.createAccessToken( userRepository.findByEmail(SeedFixtures.SYSADMIN_EMAIL).orElseThrow()); - mockMvc.perform(delete("/api/v1/workspaces/" + workspaceId) + mockMvc.perform(delete("/api/v1/workspaces/" + pub("workspaces", workspaceId)) .header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isNoContent()); @@ -168,15 +168,15 @@ void deletingWorkspaceCancelsSubmittedRequestsAndApprovalThenConflicts() throws String.class, requestId)).isEqualTo("CANCELED"); assertThat(jdbcTemplate.queryForObject(""" select count(*) from audit_logs where action='request.cancel' and target_id=? - """, Long.class, requestId)).isEqualTo(1L); + """, Long.class, pub("requests", requestId).toString())).isEqualTo(1L); // approving the now-canceled request hits the existing SUBMITTED guard → 409 - mockMvc.perform(post("/api/v1/admin/requests/" + requestId + "/approve") + mockMvc.perform(post("/api/v1/admin/requests/" + pub("requests", requestId).toString() + "/approve") .header("Authorization", "Bearer " + sysAdminToken) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(Map.of( "grantedVcpu", 1, "grantedMemoryMb", 1024, "grantedDiskGb", 10, - "grantedImageId", imageId)))) + "grantedImageId", pub("os_images", imageId))))) .andExpect(status().isConflict()) .andExpect(jsonPath("$.code").value("REQUEST_ALREADY_DECIDED")); } @@ -205,11 +205,11 @@ private long createTeam(String slug) throws Exception { Map.of("kind", "TEAM", "name", "삭제 테스트 " + slug)))) .andExpect(status().isCreated()) .andReturn().getResponse().getContentAsString(); - return objectMapper.readTree(body).get("id").asLong(); + return SeedFixtures.internalId(jdbcTemplate, "workspaces", UUID.fromString(objectMapper.readTree(body).get("id").asString())); } private void addMember(long workspaceId, String email, String role) throws Exception { - mockMvc.perform(post("/api/v1/workspaces/" + workspaceId + "/members") + mockMvc.perform(post("/api/v1/workspaces/" + pub("workspaces", workspaceId) + "/members") .header("Authorization", "Bearer " + ownerToken) .header(ReauthTestSupport.HEADER, ownerReauth) .contentType(MediaType.APPLICATION_JSON) @@ -225,4 +225,9 @@ private User ensureUser(String email, String name) { return userRepository.save(user); }); } + + /** The public identifier of a row this test set up through direct SQL. */ + private UUID pub(String table, long id) { + return SeedFixtures.publicId(jdbcTemplate, table, id); + } } diff --git a/src/test/java/kr/ac/pusan/pickle/workspace/WorkspacesTest.java b/src/test/java/kr/ac/pusan/pickle/workspace/WorkspacesTest.java index abedb689..1c6efe7d 100644 --- a/src/test/java/kr/ac/pusan/pickle/workspace/WorkspacesTest.java +++ b/src/test/java/kr/ac/pusan/pickle/workspace/WorkspacesTest.java @@ -11,9 +11,11 @@ import java.time.Instant; import java.util.HashMap; 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.SeedFixtures; import kr.ac.pusan.pickle.user.User; import kr.ac.pusan.pickle.user.UserRepository; import kr.ac.pusan.pickle.user.UserStatus; @@ -95,7 +97,7 @@ void createValidatesKindAndListsMyWorkspaces() throws Exception { .andExpect(jsonPath("$.kind").value("PROJECT")) .andExpect(jsonPath("$.name").value("캡스톤 3조")) .andExpect(jsonPath("$.members.length()").value(1)) - .andExpect(jsonPath("$.members[0].userId").value(owner.getId())) + .andExpect(jsonPath("$.members[0].userId").value(owner.getPublicId().toString())) .andExpect(jsonPath("$.members[0].role").value("OWNER")) .andExpect(jsonPath("$.createdAt").isNotEmpty()); @@ -143,7 +145,7 @@ void memberManagementIsOwnerOnly() throws Exception { // OWNER adds members: MEMBER is the only rung an addition may name addMember(ownerToken, workspaceId, peer.getEmail(), "MEMBER") .andExpect(status().isCreated()) - .andExpect(jsonPath("$.userId").value(peer.getId())) + .andExpect(jsonPath("$.userId").value(peer.getPublicId().toString())) .andExpect(jsonPath("$.role").value("MEMBER")); addMember(ownerToken, workspaceId, member.getEmail(), "MEMBER").andExpect(status().isCreated()); @@ -182,14 +184,14 @@ void memberManagementIsOwnerOnly() throws Exception { .andExpect(jsonPath("$.errors[0].field").value("role")); // detail is member-only: member sees everyone, outsider gets 403 - mockMvc.perform(get("/api/v1/workspaces/" + workspaceId).header("Authorization", "Bearer " + memberToken)) + mockMvc.perform(get("/api/v1/workspaces/" + pub("workspaces", workspaceId)).header("Authorization", "Bearer " + memberToken)) .andExpect(status().isOk()) .andExpect(jsonPath("$.members.length()").value(3)) .andExpect(jsonPath("$.members[0].email").value(owner.getEmail())); - mockMvc.perform(get("/api/v1/workspaces/" + workspaceId).header("Authorization", "Bearer " + outsiderToken)) + mockMvc.perform(get("/api/v1/workspaces/" + pub("workspaces", workspaceId)).header("Authorization", "Bearer " + outsiderToken)) .andExpect(status().isForbidden()) .andExpect(jsonPath("$.code").value("ACCESS_DENIED")); - mockMvc.perform(get("/api/v1/workspaces/999999").header("Authorization", "Bearer " + ownerToken)) + mockMvc.perform(get("/api/v1/workspaces/" + SeedFixtures.UNKNOWN_ID).header("Authorization", "Bearer " + ownerToken)) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND")); } @@ -201,27 +203,27 @@ void workspaceInfoUpdateIsOwnerOnly() throws Exception { addMember(ownerToken, workspaceId, member.getEmail(), "MEMBER").andExpect(status().isCreated()); // OWNER may edit name/description - patchJson("/api/v1/workspaces/" + workspaceId, ownerToken, Map.of("name", "새 이름")) + patchJson("/api/v1/workspaces/" + pub("workspaces", workspaceId), ownerToken, Map.of("name", "새 이름")) .andExpect(status().isOk()) .andExpect(jsonPath("$.name").value("새 이름")); // explicit null clears the description Map clearDescription = new HashMap<>(); clearDescription.put("description", null); - patchJson("/api/v1/workspaces/" + workspaceId, ownerToken, clearDescription) + patchJson("/api/v1/workspaces/" + pub("workspaces", workspaceId), ownerToken, clearDescription) .andExpect(status().isOk()) .andExpect(jsonPath("$.description").value((Object) null)); // members / outsider → 403, empty patch → 422 (contract: OWNER only) - patchJson("/api/v1/workspaces/" + workspaceId, peerToken, Map.of("name", "몰래 수정")) + patchJson("/api/v1/workspaces/" + pub("workspaces", workspaceId), peerToken, Map.of("name", "몰래 수정")) .andExpect(status().isForbidden()) .andExpect(jsonPath("$.code").value("ACCESS_DENIED")); - patchJson("/api/v1/workspaces/" + workspaceId, memberToken, Map.of("name", "몰래 수정")) + patchJson("/api/v1/workspaces/" + pub("workspaces", workspaceId), memberToken, Map.of("name", "몰래 수정")) .andExpect(status().isForbidden()) .andExpect(jsonPath("$.code").value("ACCESS_DENIED")); - patchJson("/api/v1/workspaces/" + workspaceId, outsiderToken, Map.of("name", "몰래 수정")) + patchJson("/api/v1/workspaces/" + pub("workspaces", workspaceId), outsiderToken, Map.of("name", "몰래 수정")) .andExpect(status().isForbidden()); - patchJson("/api/v1/workspaces/" + workspaceId, ownerToken, Map.of()) + patchJson("/api/v1/workspaces/" + pub("workspaces", workspaceId), ownerToken, Map.of()) .andExpect(status().isUnprocessableContent()) .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); } @@ -233,11 +235,11 @@ void ownerAppointmentAndLastOwnerProtection() throws Exception { addMember(ownerToken, workspaceId, member.getEmail(), "MEMBER").andExpect(status().isCreated()); // plain members cannot change roles or remove others - patchJson("/api/v1/workspaces/" + workspaceId + "/members/" + member.getId(), peerToken, + patchJson("/api/v1/workspaces/" + pub("workspaces", workspaceId) + "/members/" + member.getPublicId(), peerToken, Map.of("role", "MEMBER")) .andExpect(status().isForbidden()) .andExpect(jsonPath("$.code").value("WORKSPACE_MEMBER_MANAGE_FORBIDDEN")); - mockMvc.perform(delete("/api/v1/workspaces/" + workspaceId + "/members/" + peer.getId()) + mockMvc.perform(delete("/api/v1/workspaces/" + pub("workspaces", workspaceId) + "/members/" + peer.getPublicId()) .header("Authorization", "Bearer " + memberToken) .header(ReauthTestSupport.HEADER, reauth(memberToken))) .andExpect(status().isForbidden()) @@ -245,26 +247,26 @@ void ownerAppointmentAndLastOwnerProtection() throws Exception { // the last owner can neither step down nor leave: the workspace would be // left with nobody who can add members or appoint a replacement - patchJson("/api/v1/workspaces/" + workspaceId + "/members/" + owner.getId(), ownerToken, + patchJson("/api/v1/workspaces/" + pub("workspaces", workspaceId) + "/members/" + owner.getPublicId(), ownerToken, Map.of("role", "MEMBER")) .andExpect(status().isConflict()) .andExpect(jsonPath("$.code").value("WORKSPACE_SOLE_OWNER_REMOVAL")); - mockMvc.perform(delete("/api/v1/workspaces/" + workspaceId + "/members/" + owner.getId()) + mockMvc.perform(delete("/api/v1/workspaces/" + pub("workspaces", workspaceId) + "/members/" + owner.getPublicId()) .header("Authorization", "Bearer " + ownerToken) .header(ReauthTestSupport.HEADER, reauth(ownerToken))) .andExpect(status().isConflict()) .andExpect(jsonPath("$.code").value("WORKSPACE_SOLE_OWNER_REMOVAL")); // ownership is appointed, not handed over: both are OWNER afterwards - patchJson("/api/v1/workspaces/" + workspaceId + "/members/" + peer.getId(), ownerToken, + patchJson("/api/v1/workspaces/" + pub("workspaces", workspaceId) + "/members/" + peer.getPublicId(), ownerToken, Map.of("role", "OWNER")) .andExpect(status().isOk()) .andExpect(jsonPath("$.role").value("OWNER")); - mockMvc.perform(get("/api/v1/workspaces/" + workspaceId).header("Authorization", "Bearer " + peerToken)) + mockMvc.perform(get("/api/v1/workspaces/" + pub("workspaces", workspaceId)).header("Authorization", "Bearer " + peerToken)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.members[?(@.userId == %d)].role".formatted(owner.getId())) + .andExpect(jsonPath("$.members[?(@.userId == \'%s\')].role".formatted(owner.getPublicId())) .value(org.hamcrest.Matchers.contains("OWNER"))) - .andExpect(jsonPath("$.members[?(@.userId == %d)].role".formatted(peer.getId())) + .andExpect(jsonPath("$.members[?(@.userId == \'%s\')].role".formatted(peer.getPublicId())) .value(org.hamcrest.Matchers.contains("OWNER"))); // appointing somebody costs the appointer nothing — they still manage @@ -272,31 +274,31 @@ void ownerAppointmentAndLastOwnerProtection() throws Exception { .andExpect(status().isCreated()); // with a second owner in place the first may now release ownership - patchJson("/api/v1/workspaces/" + workspaceId + "/members/" + owner.getId(), ownerToken, + patchJson("/api/v1/workspaces/" + pub("workspaces", workspaceId) + "/members/" + owner.getPublicId(), ownerToken, Map.of("role", "MEMBER")) .andExpect(status().isOk()) .andExpect(jsonPath("$.role").value("MEMBER")); // and from then on manages nothing - patchJson("/api/v1/workspaces/" + workspaceId + "/members/" + member.getId(), ownerToken, + patchJson("/api/v1/workspaces/" + pub("workspaces", workspaceId) + "/members/" + member.getPublicId(), ownerToken, Map.of("role", "OWNER")) .andExpect(status().isForbidden()) .andExpect(jsonPath("$.code").value("WORKSPACE_MEMBER_MANAGE_FORBIDDEN")); // non-OWNER may leave on their own - mockMvc.perform(delete("/api/v1/workspaces/" + workspaceId + "/members/" + member.getId()) + mockMvc.perform(delete("/api/v1/workspaces/" + pub("workspaces", workspaceId) + "/members/" + member.getPublicId()) .header("Authorization", "Bearer " + memberToken) .header(ReauthTestSupport.HEADER, reauth(memberToken))) .andExpect(status().isNoContent()); // the remaining owner removes the one who released ownership - mockMvc.perform(delete("/api/v1/workspaces/" + workspaceId + "/members/" + owner.getId()) + mockMvc.perform(delete("/api/v1/workspaces/" + pub("workspaces", workspaceId) + "/members/" + owner.getPublicId()) .header("Authorization", "Bearer " + peerToken) .header(ReauthTestSupport.HEADER, reauth(peerToken))) .andExpect(status().isNoContent()); // role change for someone who is not a member → 404 - patchJson("/api/v1/workspaces/" + workspaceId + "/members/" + member.getId(), peerToken, + patchJson("/api/v1/workspaces/" + pub("workspaces", workspaceId) + "/members/" + member.getPublicId(), peerToken, Map.of("role", "MEMBER")) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND")); @@ -306,7 +308,7 @@ void ownerAppointmentAndLastOwnerProtection() throws Exception { select count(*) from audit_logs where action in ('workspace.member_add', 'workspace.member_update', 'workspace.member_remove') and target_id = ? - """, Long.class, workspaceId); + """, Long.class, pub("workspaces", workspaceId).toString()); assertThat(audits).isGreaterThanOrEqualTo(6); } @@ -320,7 +322,7 @@ void personalWorkspaceMembershipIsImmutable() throws Exception { long personalWorkspaceId = -1; for (int i = 0; i < workspaces.size(); i++) { if ("PERSONAL".equals(workspaces.get(i).path("kind").asString())) { - personalWorkspaceId = workspaces.get(i).path("id").asLong(); + personalWorkspaceId = SeedFixtures.internalId(jdbcTemplate, "workspaces", UUID.fromString(workspaces.get(i).path("id").asString())); break; } } @@ -329,11 +331,11 @@ void personalWorkspaceMembershipIsImmutable() throws Exception { addMember(ownerToken, personalWorkspaceId, peer.getEmail(), "MEMBER") .andExpect(status().isForbidden()) .andExpect(jsonPath("$.code").value("WORKSPACE_MEMBER_MANAGE_FORBIDDEN")); - patchJson("/api/v1/workspaces/" + personalWorkspaceId + "/members/" + owner.getId(), ownerToken, + patchJson("/api/v1/workspaces/" + pub("workspaces", personalWorkspaceId) + "/members/" + owner.getPublicId(), ownerToken, Map.of("role", "MEMBER")) .andExpect(status().isForbidden()) .andExpect(jsonPath("$.code").value("WORKSPACE_MEMBER_MANAGE_FORBIDDEN")); - mockMvc.perform(delete("/api/v1/workspaces/" + personalWorkspaceId + "/members/" + owner.getId()) + mockMvc.perform(delete("/api/v1/workspaces/" + pub("workspaces", personalWorkspaceId) + "/members/" + owner.getPublicId()) .header("Authorization", "Bearer " + ownerToken) .header(ReauthTestSupport.HEADER, reauth(ownerToken))) .andExpect(status().isForbidden()) @@ -345,11 +347,11 @@ private long createWorkspace(String token, String slug) throws Exception { Map.of("kind", "TEAM", "name", "테스트 워크스페이스 " + slug)) .andExpect(status().isCreated()) .andReturn().getResponse().getContentAsString(); - return objectMapper.readTree(body).get("id").asLong(); + return SeedFixtures.internalId(jdbcTemplate, "workspaces", UUID.fromString(objectMapper.readTree(body).get("id").asString())); } private ResultActions addMember(String token, long workspaceId, String email, String role) throws Exception { - return postJson("/api/v1/workspaces/" + workspaceId + "/members", token, Map.of("email", email, "role", role)); + return postJson("/api/v1/workspaces/" + pub("workspaces", workspaceId) + "/members", token, Map.of("email", email, "role", role)); } private ResultActions postJson(String uri, String token, Map body) throws Exception { @@ -378,4 +380,9 @@ private User ensureUser(String email, String name, UserStatus status) { return userRepository.save(user); }); } + + /** The public identifier of a row this test set up through direct SQL. */ + private UUID pub(String table, long id) { + return SeedFixtures.publicId(jdbcTemplate, table, id); + } }