diff --git a/docs/database/postgresql-rls-rollout.md b/docs/database/postgresql-rls-rollout.md index 2c25ba2..0d2b9fd 100644 --- a/docs/database/postgresql-rls-rollout.md +++ b/docs/database/postgresql-rls-rollout.md @@ -20,15 +20,19 @@ transaction-local tenant context와 connection pool 비누수 테스트를 준 JWT로 인증된 Worker·Task·Approval·Audit 업무 transaction은 요청 값이 아니라 `ActorContext.companyId`를 transaction-local context의 신뢰 원본으로 사용합니다. H2는 PostgreSQL custom setting을 흉내 내지 않고 transaction 경계만 검증합니다. -`V10`에서 bootstrap 함수와 tenant 테이블 RLS policy를 생성했으며, RLS는 아직 활성화하지 않았습니다. +`V10`에서 공통 bootstrap 함수와 기존 tenant 테이블 policy를, `V13`에서 Worker Link +bootstrap 함수와 policy를, `V14`에서 AI 실행 테이블 policy를 생성했습니다. `V21`은 +V20에서 추가한 직접 tenant 컬럼에 맞춰 Worker Link 업로드 policy를 단순화하고 업로드 +멱등성 테이블 policy를 추가합니다. Worker Link bootstrap도 ACTIVE이면서 DB 시각 기준 +미만료인 링크만 사업장을 반환하도록 제한합니다. RLS는 아직 활성화하지 않았습니다. 로그인·Refresh Token·Logout은 tenant context가 생기기 전 최소 bootstrap 조회가 필요합니다. Issue #34 작성 뒤 추가된 사업장 회원가입도 새 tenant 행을 처음 만드는 -별도 bootstrap 흐름으로 함께 검토해야 합니다. Worker Link는 해당 기능이 구현된 뒤 -같은 기준으로 확장합니다. +별도 bootstrap 흐름으로 함께 검토해야 합니다. Worker Link는 `V13`에서 같은 기준으로 +확장했습니다. -현재 `main`의 V1~V9에는 `company_id`를 직접 보유한 아래 16개 tenant table과, -부모 초안의 tenant를 따르는 `document_request_draft_type`이 존재합니다. 기반 단계의 +`V20` 적용 후에는 `company_id`를 직접 보유한 아래 tenant table과, 부모 초안의 tenant를 +따르는 `document_request_draft_type`이 존재합니다. 기반 단계의 제한 role 테스트는 이 전체 범위에 업무 DML만 허용하고, table owner·DDL·`TRUNCATE`· `REFERENCES` 권한과 RLS 우회 권한이 없음을 확인합니다. @@ -38,6 +42,27 @@ H2는 PostgreSQL custom setting을 흉내 내지 않고 transaction 경계만 - `approval_request`, `external_submission`, `task_evidence`, `audit_event` - `event_publication`, `event_consumption` - `document_request_draft`, `document_request_draft_type` +- `worker_link`, `worker_response`, `worker_response_upload` +- `worker_document_upload_idempotency` +- `ai_run`, `ai_attempt`, `ai_question`, `ai_candidate` + +### V20 최초 배포 전제 + +`V20`은 Worker Link 자식 테이블의 `company_id`를 backfill하고 `NOT NULL`, 복합 +`UNIQUE`, tenant-aware 복합 FK를 한 번에 적용합니다. 또한 `worker_document`의 Task +참조를 `(task_id, worker_id, company_id)` 복합 FK로 전환합니다. + +이 migration은 pre-V20 애플리케이션이 같은 DB에 계속 쓰는 상황과 +backward-compatible하지 않습니다. 현재는 운영 DB·운영 트래픽·구버전 Pod가 없는 최초 +배포 전이므로 이 전제를 충족하며 expand-contract migration을 적용하지 않습니다. 이 +전제를 충족하지 않는 환경에 적용할 때는 쓰기 중단 또는 expand-contract 절차를 먼저 +설계해야 합니다. + +기존 개발·테스트 데이터는 신뢰할 수 있는 부모 관계에서 사업장을 복원합니다. +`worker_response_upload`는 `worker_response`, +`worker_document_upload_idempotency`는 `worker_link`를 기준으로 backfill합니다. 복원한 +사업장이 `stored_file.company_id`와 다르거나 NULL·orphan·교차 tenant 관계가 남으면 +migration을 실패시키며, 임의 사업장으로 보정하거나 행을 삭제하지 않습니다. `document_request_draft_type`에는 `company_id`가 없으므로 부모 `document_request_draft`의 `draft_id`와 현재 tenant context를 확인하는 `EXISTS` diff --git a/docs/demo-seed-fixture-manifest.md b/docs/demo-seed-fixture-manifest.md index feeeaa2..c8aef59 100644 --- a/docs/demo-seed-fixture-manifest.md +++ b/docs/demo-seed-fixture-manifest.md @@ -65,9 +65,11 @@ PDF는 모두 합성 데이터이며 `DEMO / SAMPLE - NOT FOR OFFICIAL SUBMISSIO 검증한다. Demo Seed가 활성화되면 classpath PDF를 `app.file-storage.local-path`에 설치한다. -동일한 storage key의 파일은 크기와 SHA-256이 같을 때만 재사용하며, 다른 파일은 -덮어쓰지 않고 시작을 중단한다. 파일만 남거나 DB row만 남은 상태는 동일 fixture인 -경우 복구한다. 현재 installer는 `LocalFileStorage` 구성만 지원한다. +동일한 storage key의 일반 파일은 크기와 SHA-256이 같으면 재사용하고, classpath +fixture가 변경되었으면 현재 내용으로 원자적으로 교체한다. 디렉터리나 심볼릭 링크처럼 +일반 파일이 아닌 경로는 덮어쓰지 않고 시작을 중단한다. DB row의 예약 ID·소유권·메타데이터 +충돌도 기존 값을 덮어쓰지 않고 시작을 중단한다. 파일만 남거나 DB row만 남은 상태는 +동일 fixture인 경우 복구한다. 현재 installer는 `LocalFileStorage` 구성만 지원한다. ## 근로자와 지원 언어 diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoFileFixtureInstaller.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoFileFixtureInstaller.java index 4cce70f..dc28f76 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoFileFixtureInstaller.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoFileFixtureInstaller.java @@ -5,8 +5,8 @@ import java.io.InputStream; import java.io.UncheckedIOException; import java.nio.file.AtomicMoveNotSupportedException; -import java.nio.file.FileAlreadyExistsException; import java.nio.file.Files; +import java.nio.file.LinkOption; import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.security.MessageDigest; @@ -31,16 +31,16 @@ void install(StoredFileSeed seed) { Path target = target(seed.storageKey()); try { Files.createDirectories(rootDirectory); - if (Files.exists(target)) { - verify(target, expected, seed); + if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) { + requireRegularFile(target, seed); + } + if (matches(target, expected)) { return; } Path temporary = Files.createTempFile(rootDirectory, ".demo-fixture-", ".tmp"); try { Files.write(temporary, expected); - moveWithoutOverwrite(temporary, target); - } catch (FileAlreadyExistsException exception) { - verify(target, expected, seed); + moveReplacing(temporary, target); } finally { Files.deleteIfExists(temporary); } @@ -76,24 +76,41 @@ private Path target(String storageKey) { return target; } - private void moveWithoutOverwrite(Path source, Path target) throws IOException { + private void moveReplacing(Path source, Path target) throws IOException { try { - Files.move(source, target, StandardCopyOption.ATOMIC_MOVE); + Files.move( + source, + target, + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING + ); } catch (AtomicMoveNotSupportedException exception) { - Files.move(source, target); + Files.move(source, target, StandardCopyOption.REPLACE_EXISTING); } } private void verify(Path target, byte[] expected, StoredFileSeed seed) throws IOException { - if (!Files.isRegularFile(target) - || Files.size(target) != expected.length - || !Arrays.equals(sha256(Files.readAllBytes(target)), sha256(expected))) { + if (!matches(target, expected)) { throw new IllegalStateException( "a demo file storage key already contains different content: " + seed.storageKey() ); } } + private boolean matches(Path target, byte[] expected) throws IOException { + return Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS) + && Files.size(target) == expected.length + && Arrays.equals(sha256(Files.readAllBytes(target)), sha256(expected)); + } + + private void requireRegularFile(Path target, StoredFileSeed seed) { + if (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS)) { + throw new IllegalStateException( + "a demo file storage key is not a regular file: " + seed.storageKey() + ); + } + } + private byte[] sha256(byte[] content) { try { return MessageDigest.getInstance("SHA-256").digest(content); diff --git a/src/main/java/com/fowoco/server/worker/application/WorkerDocumentService.java b/src/main/java/com/fowoco/server/worker/application/WorkerDocumentService.java index 0593bbc..22fb2d1 100644 --- a/src/main/java/com/fowoco/server/worker/application/WorkerDocumentService.java +++ b/src/main/java/com/fowoco/server/worker/application/WorkerDocumentService.java @@ -13,6 +13,9 @@ import com.fowoco.server.common.time.DatabaseTimestamp; import com.fowoco.server.common.web.RequestMetadata; import com.fowoco.server.file.application.port.StoredFileRepository; +import com.fowoco.server.task.application.error.TaskErrorCode; +import com.fowoco.server.task.application.port.TaskRepository; +import com.fowoco.server.task.domain.Task; import com.fowoco.server.worker.application.error.WorkerErrorCode; import com.fowoco.server.worker.application.port.WorkerDocumentRepository; import com.fowoco.server.worker.application.port.WorkerRepository; @@ -31,6 +34,7 @@ public class WorkerDocumentService { private final WorkerDocumentRepository workerDocumentRepository; private final WorkerRepository workerRepository; + private final TaskRepository taskRepository; private final StoredFileRepository storedFileRepository; private final AuditEventRepository auditRepository; private final TenantDatabaseContext tenantDatabaseContext; @@ -40,6 +44,7 @@ public class WorkerDocumentService { public WorkerDocumentService( WorkerDocumentRepository workerDocumentRepository, WorkerRepository workerRepository, + TaskRepository taskRepository, StoredFileRepository storedFileRepository, AuditEventRepository auditRepository, TenantDatabaseContext tenantDatabaseContext, @@ -48,6 +53,7 @@ public WorkerDocumentService( ) { this.workerDocumentRepository = workerDocumentRepository; this.workerRepository = workerRepository; + this.taskRepository = taskRepository; this.storedFileRepository = storedFileRepository; this.auditRepository = auditRepository; this.tenantDatabaseContext = tenantDatabaseContext; @@ -60,6 +66,7 @@ public WorkerDocument register(WorkerDocumentCreateCommand command, ActorContext bindTenant(actor); workerRepository.findByWorkerIdAndCompanyId(command.workerId(), actor.companyId()) .orElseThrow(() -> new ApiException(WorkerErrorCode.WORKER_NOT_FOUND)); + validateTaskAssignment(command, actor.companyId()); WorkerDocument document = WorkerDocument.create( uuidGenerator.generate(), @@ -154,6 +161,17 @@ private UUID resolveFileId(UUID requestedFileId, UUID companyId, UUID existingFi return requestedFileId; } + private void validateTaskAssignment(WorkerDocumentCreateCommand command, UUID companyId) { + if (command.taskId() == null) { + return; + } + Task task = taskRepository.findByIdAndCompanyId(command.taskId(), companyId) + .orElseThrow(() -> new ApiException(TaskErrorCode.TASK_NOT_FOUND)); + if (!task.workerId().equals(command.workerId())) { + throw new ApiException(WorkerErrorCode.WORKER_DOCUMENT_TASK_WORKER_MISMATCH); + } + } + private void appendAudit( ActorContext actor, AuditAction action, diff --git a/src/main/java/com/fowoco/server/worker/application/error/WorkerErrorCode.java b/src/main/java/com/fowoco/server/worker/application/error/WorkerErrorCode.java index d1ca6fe..5194045 100644 --- a/src/main/java/com/fowoco/server/worker/application/error/WorkerErrorCode.java +++ b/src/main/java/com/fowoco/server/worker/application/error/WorkerErrorCode.java @@ -8,7 +8,11 @@ public enum WorkerErrorCode implements ApiErrorCode { WORKER_VERSION_CONFLICT(HttpStatus.CONFLICT, "다른 사용자가 먼저 수정했습니다. 새로고침 후 다시 시도해 주세요."), WORKER_DOCUMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "서류를 찾을 수 없습니다."), WORKER_DOCUMENT_VERSION_CONFLICT(HttpStatus.CONFLICT, "다른 사용자가 먼저 수정했습니다. 새로고침 후 다시 시도해 주세요."), - WORKER_DOCUMENT_FILE_NOT_FOUND(HttpStatus.NOT_FOUND, "연결할 파일을 찾을 수 없습니다."); + WORKER_DOCUMENT_FILE_NOT_FOUND(HttpStatus.NOT_FOUND, "연결할 파일을 찾을 수 없습니다."), + WORKER_DOCUMENT_TASK_WORKER_MISMATCH( + HttpStatus.UNPROCESSABLE_CONTENT, + "업무카드의 근로자와 서류의 근로자가 일치하지 않습니다." + ); private final HttpStatus status; private final String defaultMessage; diff --git a/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkController.java b/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkController.java index 7b53839..6867733 100644 --- a/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkController.java +++ b/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkController.java @@ -76,13 +76,11 @@ public ResponseEntity issue( ActorContext actor = actorContextProvider.requireCurrentActor(); WorkerLinkIssueCommand command = new WorkerLinkIssueCommand( taskId, - actor.companyId(), request.getExpiresInHours(), request.isRotateExisting(), - actor.actorId(), idempotencyKey ); - WorkerLinkIssueResult result = workerLinkService.issue(command); + WorkerLinkIssueResult result = workerLinkService.issue(command, actor); return ResponseEntity.status(HttpStatus.CREATED).body(WorkerLinkIssueResponse.from(result)); } } diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkDocumentService.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkDocumentService.java index 0bf8269..92a88da 100644 --- a/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkDocumentService.java +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkDocumentService.java @@ -102,7 +102,7 @@ public WorkerLinkDocumentUploadResult upload(WorkerLinkDocumentUploadCommand com } Optional existingStoredFileId = uploadIdempotencyRepository - .findStoredFileId(link.workerLinkId(), command.clientRequestId()); + .findStoredFileId(link.workerLinkId(), companyId, command.clientRequestId()); if (existingStoredFileId.isPresent()) { StoredFile existingFile = storedFileRepository.findByIdAndCompanyId(existingStoredFileId.get(), companyId) .orElseThrow(() -> new ApiException(WorkerLinkErrorCode.UPLOAD_NOT_AVAILABLE)); @@ -130,7 +130,12 @@ public WorkerLinkDocumentUploadResult upload(WorkerLinkDocumentUploadCommand com fileStorage.store(storageKey, command.content(), command.size(), command.mimeType()); storedFileRepository.insert(verifiedFile); - uploadIdempotencyRepository.save(link.workerLinkId(), command.clientRequestId(), storedFileId); + uploadIdempotencyRepository.save( + link.workerLinkId(), + companyId, + command.clientRequestId(), + storedFileId + ); auditRepository.append(new AuditEvent( uuidGenerator.generate(), diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkIssueCommand.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkIssueCommand.java index ba3c0f7..fd31f2f 100644 --- a/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkIssueCommand.java +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkIssueCommand.java @@ -5,25 +5,19 @@ public final class WorkerLinkIssueCommand { private final UUID taskId; - private final UUID companyId; private final Long expiresInHours; private final boolean rotateExisting; - private final UUID issuedBy; private final String idempotencyKey; public WorkerLinkIssueCommand( UUID taskId, - UUID companyId, Long expiresInHours, boolean rotateExisting, - UUID issuedBy, String idempotencyKey ) { this.taskId = taskId; - this.companyId = companyId; this.expiresInHours = expiresInHours; this.rotateExisting = rotateExisting; - this.issuedBy = issuedBy; this.idempotencyKey = idempotencyKey; } @@ -31,10 +25,6 @@ public UUID taskId() { return taskId; } - public UUID companyId() { - return companyId; - } - public Long expiresInHours() { return expiresInHours; } @@ -43,10 +33,6 @@ public boolean rotateExisting() { return rotateExisting; } - public UUID issuedBy() { - return issuedBy; - } - public String idempotencyKey() { return idempotencyKey; } diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkService.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkService.java index 53d52a3..8c2a307 100644 --- a/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkService.java +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkService.java @@ -2,9 +2,10 @@ import com.fowoco.server.approval.application.port.ApprovalRequestRepository; import com.fowoco.server.approval.domain.ApprovalRequest; +import com.fowoco.server.auth.application.ActorContext; import com.fowoco.server.common.error.ApiException; import com.fowoco.server.common.id.UuidGenerator; -import com.fowoco.server.task.application.error.TaskErrorCode; +import com.fowoco.server.common.security.TenantDatabaseContext; import com.fowoco.server.task.application.port.TaskRepository; import com.fowoco.server.task.domain.Task; import com.fowoco.server.workerlink.application.error.WorkerLinkErrorCode; @@ -29,6 +30,7 @@ public class WorkerLinkService { private final WorkerLinkRepository workerLinkRepository; private final WorkerLinkGenerator workerLinkGenerator; private final WorkerLinkHasher workerLinkHasher; + private final TenantDatabaseContext tenantDatabaseContext; private final UuidGenerator uuidGenerator; private final Clock clock; @@ -38,6 +40,7 @@ public WorkerLinkService( WorkerLinkRepository workerLinkRepository, WorkerLinkGenerator workerLinkGenerator, WorkerLinkHasher workerLinkHasher, + TenantDatabaseContext tenantDatabaseContext, UuidGenerator uuidGenerator, Clock clock ) { @@ -46,17 +49,20 @@ public WorkerLinkService( this.workerLinkRepository = workerLinkRepository; this.workerLinkGenerator = workerLinkGenerator; this.workerLinkHasher = workerLinkHasher; + this.tenantDatabaseContext = tenantDatabaseContext; this.uuidGenerator = uuidGenerator; this.clock = clock; } @Transactional - public WorkerLinkIssueResult issue(WorkerLinkIssueCommand command) { - Task task = taskRepository.findByIdAndCompanyId(command.taskId(), command.companyId()) + public WorkerLinkIssueResult issue(WorkerLinkIssueCommand command, ActorContext actor) { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(actor.companyId()); + + Task task = taskRepository.findByIdAndCompanyId(command.taskId(), actor.companyId()) .orElseThrow(() -> new ApiException(WorkerLinkErrorCode.TASK_NOT_FOUND)); ApprovalRequest approval = approvalRequestRepository - .findLatestApprovedByTaskIdAndCompanyId(command.taskId(), command.companyId()) + .findLatestApprovedByTaskIdAndCompanyId(command.taskId(), actor.companyId()) .orElseThrow(() -> new ApiException(WorkerLinkErrorCode.TASK_NOT_APPROVED)); if (!approval.isValidFor(task.contentRevision(), task.criticalFingerprint())) { @@ -73,7 +79,7 @@ public WorkerLinkIssueResult issue(WorkerLinkIssueCommand command) { Instant now = clock.instant(); Optional existingActive = workerLinkRepository - .findActiveByTaskIdAndCompanyId(command.taskId(), command.companyId()); + .findActiveByTaskIdAndCompanyId(command.taskId(), actor.companyId()); WorkerLink previousLink = null; if (existingActive.isPresent()) { @@ -92,10 +98,10 @@ public WorkerLinkIssueResult issue(WorkerLinkIssueCommand command) { WorkerLink workerLink = WorkerLink.issue( uuidGenerator.generate(), command.taskId(), - command.companyId(), + actor.companyId(), generated.tokenHash(), expiresAt, - command.issuedBy(), + actor.actorId(), previousLink != null ? previousLink.workerLinkId() : null, idempotencyKeyHash, now diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseService.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseService.java index 158e296..f75d8e4 100644 --- a/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseService.java +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseService.java @@ -12,6 +12,7 @@ import com.fowoco.server.file.application.port.StoredFileRepository; import com.fowoco.server.file.domain.StoredFile; import com.fowoco.server.workerlink.application.error.WorkerLinkErrorCode; +import com.fowoco.server.workerlink.application.error.WorkerResponseUploadAlreadyLinkedException; import com.fowoco.server.workerlink.application.port.WorkerLinkRepository; import com.fowoco.server.workerlink.application.port.WorkerLinkTenantBootstrap; import com.fowoco.server.workerlink.application.port.WorkerResponseRepository; @@ -96,7 +97,7 @@ public WorkerResponseSubmitResult submit(WorkerResponseSubmitCommand command, Re if (!storedFile.verified() || !link.taskId().equals(storedFile.taskId())) { throw new ApiException(WorkerLinkErrorCode.UPLOAD_NOT_AVAILABLE); } - if (workerResponseRepository.isUploadAlreadyLinked(uploadId)) { + if (workerResponseRepository.isUploadAlreadyLinked(uploadId, companyId)) { throw new ApiException(WorkerLinkErrorCode.UPLOAD_NOT_AVAILABLE); } } @@ -114,7 +115,11 @@ public WorkerResponseSubmitResult submit(WorkerResponseSubmitCommand command, Re workerResponseRepository.insert(response); for (UUID uploadId : uploadIds) { - workerResponseRepository.linkUpload(responseId, uploadId); + try { + workerResponseRepository.linkUpload(responseId, uploadId, companyId); + } catch (WorkerResponseUploadAlreadyLinkedException exception) { + throw new ApiException(WorkerLinkErrorCode.UPLOAD_NOT_AVAILABLE); + } } if (command.responseType() == WorkerResponseType.QUESTION diff --git a/src/main/java/com/fowoco/server/workerlink/application/error/WorkerResponseUploadAlreadyLinkedException.java b/src/main/java/com/fowoco/server/workerlink/application/error/WorkerResponseUploadAlreadyLinkedException.java new file mode 100644 index 0000000..976125c --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/application/error/WorkerResponseUploadAlreadyLinkedException.java @@ -0,0 +1,8 @@ +package com.fowoco.server.workerlink.application.error; + +public class WorkerResponseUploadAlreadyLinkedException extends RuntimeException { + + public WorkerResponseUploadAlreadyLinkedException(Throwable cause) { + super(cause); + } +} diff --git a/src/main/java/com/fowoco/server/workerlink/application/port/WorkerDocumentUploadIdempotencyRepository.java b/src/main/java/com/fowoco/server/workerlink/application/port/WorkerDocumentUploadIdempotencyRepository.java index da488d6..23e91c4 100644 --- a/src/main/java/com/fowoco/server/workerlink/application/port/WorkerDocumentUploadIdempotencyRepository.java +++ b/src/main/java/com/fowoco/server/workerlink/application/port/WorkerDocumentUploadIdempotencyRepository.java @@ -5,7 +5,7 @@ public interface WorkerDocumentUploadIdempotencyRepository { - Optional findStoredFileId(UUID workerLinkId, String clientRequestId); + Optional findStoredFileId(UUID workerLinkId, UUID companyId, String clientRequestId); - void save(UUID workerLinkId, String clientRequestId, UUID storedFileId); + void save(UUID workerLinkId, UUID companyId, String clientRequestId, UUID storedFileId); } diff --git a/src/main/java/com/fowoco/server/workerlink/application/port/WorkerResponseRepository.java b/src/main/java/com/fowoco/server/workerlink/application/port/WorkerResponseRepository.java index 07ca385..7522371 100644 --- a/src/main/java/com/fowoco/server/workerlink/application/port/WorkerResponseRepository.java +++ b/src/main/java/com/fowoco/server/workerlink/application/port/WorkerResponseRepository.java @@ -10,7 +10,7 @@ public interface WorkerResponseRepository { Optional findByWorkerLinkIdAndIdempotencyKey(UUID workerLinkId, String idempotencyKey); - void linkUpload(UUID responseId, UUID storedFileId); + void linkUpload(UUID responseId, UUID storedFileId, UUID companyId); - boolean isUploadAlreadyLinked(UUID storedFileId); + boolean isUploadAlreadyLinked(UUID storedFileId, UUID companyId); } diff --git a/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerDocumentUploadIdempotencyRepository.java b/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerDocumentUploadIdempotencyRepository.java index 157b101..a1a3b2d 100644 --- a/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerDocumentUploadIdempotencyRepository.java +++ b/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerDocumentUploadIdempotencyRepository.java @@ -18,32 +18,47 @@ public JpaWorkerDocumentUploadIdempotencyRepository(EntityManager entityManager) } @Override - public Optional findStoredFileId(UUID workerLinkId, String clientRequestId) { + public Optional findStoredFileId( + UUID workerLinkId, + UUID companyId, + String clientRequestId + ) { Objects.requireNonNull(workerLinkId, "workerLinkId must not be null"); + Objects.requireNonNull(companyId, "companyId must not be null"); Objects.requireNonNull(clientRequestId, "clientRequestId must not be null"); return entityManager.createNativeQuery( "SELECT CAST(stored_file_id AS VARCHAR) FROM worker_document_upload_idempotency " - + "WHERE worker_link_id = ?1 AND client_request_id = ?2" + + "WHERE worker_link_id = ?1 AND company_id = ?2 " + + "AND client_request_id = ?3" ) .setParameter(1, workerLinkId) - .setParameter(2, clientRequestId) + .setParameter(2, companyId) + .setParameter(3, clientRequestId) .getResultStream() .findFirst() .map(result -> UUID.fromString(result.toString())); } @Override - public void save(UUID workerLinkId, String clientRequestId, UUID storedFileId) { + public void save( + UUID workerLinkId, + UUID companyId, + String clientRequestId, + UUID storedFileId + ) { Objects.requireNonNull(workerLinkId, "workerLinkId must not be null"); + Objects.requireNonNull(companyId, "companyId must not be null"); Objects.requireNonNull(clientRequestId, "clientRequestId must not be null"); Objects.requireNonNull(storedFileId, "storedFileId must not be null"); Query query = entityManager.createNativeQuery( "INSERT INTO worker_document_upload_idempotency " - + "(worker_link_id, client_request_id, stored_file_id) VALUES (?1, ?2, ?3)" + + "(worker_link_id, company_id, client_request_id, stored_file_id) " + + "VALUES (?1, ?2, ?3, ?4)" ); query.setParameter(1, workerLinkId); - query.setParameter(2, clientRequestId); - query.setParameter(3, storedFileId); + query.setParameter(2, companyId); + query.setParameter(3, clientRequestId); + query.setParameter(4, storedFileId); query.executeUpdate(); } } diff --git a/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerResponseRepository.java b/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerResponseRepository.java index d30252c..a963e7d 100644 --- a/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerResponseRepository.java +++ b/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerResponseRepository.java @@ -1,17 +1,24 @@ package com.fowoco.server.workerlink.infrastructure.persistence; +import com.fowoco.server.workerlink.application.error.WorkerResponseUploadAlreadyLinkedException; import com.fowoco.server.workerlink.application.port.WorkerResponseRepository; import com.fowoco.server.workerlink.domain.WorkerResponse; import jakarta.persistence.EntityManager; import jakarta.persistence.Query; +import java.sql.SQLException; +import java.util.Locale; import java.util.Objects; import java.util.Optional; import java.util.UUID; +import org.hibernate.exception.ConstraintViolationException; import org.springframework.stereotype.Repository; @Repository public class JpaWorkerResponseRepository implements WorkerResponseRepository { + private static final String UNIQUE_UPLOAD_FILE_CONSTRAINT = + "uq_worker_response_upload_file_company"; + private final EntityManager entityManager; public JpaWorkerResponseRepository(EntityManager entityManager) { @@ -46,25 +53,60 @@ public Optional findByWorkerLinkIdAndIdempotencyKey(UUID workerL } @Override - public void linkUpload(UUID responseId, UUID storedFileId) { + public void linkUpload(UUID responseId, UUID storedFileId, UUID companyId) { Objects.requireNonNull(responseId, "responseId must not be null"); Objects.requireNonNull(storedFileId, "storedFileId must not be null"); + Objects.requireNonNull(companyId, "companyId must not be null"); Query query = entityManager.createNativeQuery( - "INSERT INTO worker_response_upload (response_id, stored_file_id) VALUES (?1, ?2)" + "INSERT INTO worker_response_upload " + + "(response_id, stored_file_id, company_id) VALUES (?1, ?2, ?3)" ); query.setParameter(1, responseId); query.setParameter(2, storedFileId); - query.executeUpdate(); + query.setParameter(3, companyId); + try { + query.executeUpdate(); + } catch (RuntimeException exception) { + if (isUniqueUploadFileViolation(exception)) { + throw new WorkerResponseUploadAlreadyLinkedException(exception); + } + throw exception; + } } @Override - public boolean isUploadAlreadyLinked(UUID storedFileId) { + public boolean isUploadAlreadyLinked(UUID storedFileId, UUID companyId) { Objects.requireNonNull(storedFileId, "storedFileId must not be null"); + Objects.requireNonNull(companyId, "companyId must not be null"); Long count = (Long) entityManager.createNativeQuery( - "SELECT COUNT(*) FROM worker_response_upload WHERE stored_file_id = ?1" + "SELECT COUNT(*) FROM worker_response_upload " + + "WHERE stored_file_id = ?1 AND company_id = ?2" ) .setParameter(1, storedFileId) + .setParameter(2, companyId) .getSingleResult(); return count != null && count > 0; } + + static boolean isUniqueUploadFileViolation(Throwable failure) { + Throwable current = failure; + while (current != null) { + if (current instanceof ConstraintViolationException constraintViolation + && containsConstraintName(constraintViolation.getConstraintName())) { + return true; + } + if (current instanceof SQLException sqlException + && "23505".equals(sqlException.getSQLState()) + && containsConstraintName(sqlException.getMessage())) { + return true; + } + current = current.getCause(); + } + return false; + } + + private static boolean containsConstraintName(String value) { + return value != null + && value.toLowerCase(Locale.ROOT).contains(UNIQUE_UPLOAD_FILE_CONSTRAINT); + } } diff --git a/src/main/resources/db/migration-postgresql/V21__align_worker_link_rls_with_tenant_columns.sql b/src/main/resources/db/migration-postgresql/V21__align_worker_link_rls_with_tenant_columns.sql new file mode 100644 index 0000000..8be2ba2 --- /dev/null +++ b/src/main/resources/db/migration-postgresql/V21__align_worker_link_rls_with_tenant_columns.sql @@ -0,0 +1,44 @@ +CREATE OR REPLACE FUNCTION public.bootstrap_company_id_by_worker_link_token_hash( + p_token_hash TEXT +) +RETURNS UUID +LANGUAGE SQL +STABLE +SECURITY DEFINER +SET search_path = pg_catalog, public, pg_temp +AS $$ + SELECT link.company_id + FROM public.worker_link AS link + WHERE link.token_hash = p_token_hash + AND link.status = 'ACTIVE' + AND link.expires_at > CURRENT_TIMESTAMP + LIMIT 1 +$$; + +REVOKE ALL + ON FUNCTION public.bootstrap_company_id_by_worker_link_token_hash(TEXT) + FROM PUBLIC; + +ALTER POLICY pl_worker_response_upload_tenant_isolation + ON public.worker_response_upload + USING ( + company_id = + NULLIF(pg_catalog.current_setting('app.company_id', true), '')::UUID + ) + WITH CHECK ( + company_id = + NULLIF(pg_catalog.current_setting('app.company_id', true), '')::UUID + ); + +CREATE POLICY pl_worker_document_upload_idempotency_tenant_isolation + ON public.worker_document_upload_idempotency + FOR ALL + TO PUBLIC + USING ( + company_id = + NULLIF(pg_catalog.current_setting('app.company_id', true), '')::UUID + ) + WITH CHECK ( + company_id = + NULLIF(pg_catalog.current_setting('app.company_id', true), '')::UUID + ); diff --git a/src/main/resources/db/migration/V20__harden_tenant_integrity.sql b/src/main/resources/db/migration/V20__harden_tenant_integrity.sql new file mode 100644 index 0000000..998e1cd --- /dev/null +++ b/src/main/resources/db/migration/V20__harden_tenant_integrity.sql @@ -0,0 +1,110 @@ +ALTER TABLE worker_response_upload + ADD COLUMN company_id UUID; + +ALTER TABLE worker_document_upload_idempotency + ADD COLUMN company_id UUID; + +UPDATE worker_response_upload +SET company_id = ( + SELECT response.company_id + FROM worker_response response + WHERE response.response_id = worker_response_upload.response_id +); + +UPDATE worker_document_upload_idempotency +SET company_id = ( + SELECT link.company_id + FROM worker_link link + WHERE link.worker_link_id = worker_document_upload_idempotency.worker_link_id +); + +ALTER TABLE task + ADD CONSTRAINT uq_task_id_worker_company + UNIQUE (task_id, worker_id, company_id); + +ALTER TABLE worker_link + ADD CONSTRAINT uq_worker_link_id_company + UNIQUE (worker_link_id, company_id); + +ALTER TABLE worker_response + ADD CONSTRAINT uq_worker_response_id_company + UNIQUE (response_id, company_id); + +ALTER TABLE stored_file + ADD CONSTRAINT uq_stored_file_id_company + UNIQUE (stored_file_id, company_id); + +ALTER TABLE worker_response_upload + ADD CONSTRAINT uq_worker_response_upload_file_company + UNIQUE (stored_file_id, company_id); + +ALTER TABLE worker_document + ADD CONSTRAINT fk_worker_document_task_worker_company + FOREIGN KEY (task_id, worker_id, company_id) + REFERENCES task (task_id, worker_id, company_id) ON DELETE RESTRICT; + +ALTER TABLE worker_link + ADD CONSTRAINT fk_worker_link_replaces_company + FOREIGN KEY (replaces_link_id, company_id) + REFERENCES worker_link (worker_link_id, company_id) ON DELETE RESTRICT; + +ALTER TABLE worker_response + ADD CONSTRAINT fk_worker_response_link_company + FOREIGN KEY (worker_link_id, company_id) + REFERENCES worker_link (worker_link_id, company_id) ON DELETE RESTRICT; + +ALTER TABLE worker_response_upload + ADD CONSTRAINT fk_worker_response_upload_response_company + FOREIGN KEY (response_id, company_id) + REFERENCES worker_response (response_id, company_id) ON DELETE CASCADE; + +ALTER TABLE worker_response_upload + ADD CONSTRAINT fk_worker_response_upload_file_company + FOREIGN KEY (stored_file_id, company_id) + REFERENCES stored_file (stored_file_id, company_id) ON DELETE RESTRICT; + +ALTER TABLE worker_document_upload_idempotency + ADD CONSTRAINT fk_worker_document_upload_idempotency_link_company + FOREIGN KEY (worker_link_id, company_id) + REFERENCES worker_link (worker_link_id, company_id) ON DELETE RESTRICT; + +ALTER TABLE worker_document_upload_idempotency + ADD CONSTRAINT fk_worker_document_upload_idempotency_file_company + FOREIGN KEY (stored_file_id, company_id) + REFERENCES stored_file (stored_file_id, company_id) ON DELETE RESTRICT; + +ALTER TABLE worker_document + DROP CONSTRAINT fk_worker_document_task_company; + +ALTER TABLE worker_link + DROP CONSTRAINT fk_worker_link_replaces; + +ALTER TABLE worker_response + DROP CONSTRAINT fk_worker_response_link; + +ALTER TABLE worker_response_upload + DROP CONSTRAINT fk_worker_response_upload_response; + +ALTER TABLE worker_response_upload + DROP CONSTRAINT fk_worker_response_upload_file; + +ALTER TABLE worker_document_upload_idempotency + DROP CONSTRAINT fk_worker_document_upload_idempotency_link; + +ALTER TABLE worker_document_upload_idempotency + DROP CONSTRAINT fk_worker_document_upload_idempotency_file; + +ALTER TABLE worker_response_upload + ALTER COLUMN company_id SET NOT NULL; + +ALTER TABLE worker_document_upload_idempotency + ALTER COLUMN company_id SET NOT NULL; + +CREATE INDEX idx_worker_response_upload_company + ON worker_response_upload (company_id); + +CREATE INDEX idx_worker_document_upload_idempotency_company + ON worker_document_upload_idempotency (company_id); + +CREATE INDEX idx_worker_document_upload_idempotency_file_company + ON worker_document_upload_idempotency (stored_file_id, company_id); diff --git a/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java b/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java index e7cb8f1..707563e 100644 --- a/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java +++ b/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java @@ -28,6 +28,9 @@ class PostgreSqlMigrationTests { private static final String TASK_A = "13000000-0000-0000-0000-000000000001"; private static final String EVENT_A = "18000000-0000-0000-0000-000000000001"; private static final String TOKEN_HASH_A = "a".repeat(64); + private static final String ACTIVE_WORKER_LINK_TOKEN_HASH = "b".repeat(64); + private static final String REVOKED_WORKER_LINK_TOKEN_HASH = "c".repeat(64); + private static final String EXPIRED_WORKER_LINK_TOKEN_HASH = "d".repeat(64); @Test void migrationsApplyCanonicalServerSchemaOnPostgreSql() throws SQLException { @@ -83,7 +86,11 @@ private void assertSchemaContract(Connection connection) throws SQLException { "ai_attempt", "ai_question", "ai_candidate", - "workflow_case" + "workflow_case", + "worker_link", + "worker_response", + "worker_response_upload", + "worker_document_upload_idempotency" ); assertThat(columnSpecs(connection, "company")) @@ -121,6 +128,7 @@ private void assertSchemaContract(Connection connection) throws SQLException { .containsEntry("worker_document_id", new ColumnSpec("uuid", false)) .containsEntry("worker_id", new ColumnSpec("uuid", false)) .containsEntry("company_id", new ColumnSpec("uuid", false)) + .containsEntry("task_id", new ColumnSpec("uuid", true)) .containsEntry("document_type", new ColumnSpec("varchar", false)) .containsEntry("submission_status", new ColumnSpec("varchar", false)) .containsEntry("version", new ColumnSpec("int8", false)); @@ -201,6 +209,23 @@ private void assertSchemaContract(Connection connection) throws SQLException { .containsEntry("ai_attempt_id", new ColumnSpec("uuid", false)) .containsEntry("worker_id", new ColumnSpec("uuid", false)) .containsEntry("confidence", new ColumnSpec("numeric", false)); + assertThat(columnSpecs(connection, "worker_link")) + .containsEntry("worker_link_id", new ColumnSpec("uuid", false)) + .containsEntry("task_id", new ColumnSpec("uuid", false)) + .containsEntry("company_id", new ColumnSpec("uuid", false)) + .containsEntry("replaces_link_id", new ColumnSpec("uuid", true)); + assertThat(columnSpecs(connection, "worker_response")) + .containsEntry("response_id", new ColumnSpec("uuid", false)) + .containsEntry("worker_link_id", new ColumnSpec("uuid", false)) + .containsEntry("company_id", new ColumnSpec("uuid", false)); + assertThat(columnSpecs(connection, "worker_response_upload")) + .containsEntry("response_id", new ColumnSpec("uuid", false)) + .containsEntry("stored_file_id", new ColumnSpec("uuid", false)) + .containsEntry("company_id", new ColumnSpec("uuid", false)); + assertThat(columnSpecs(connection, "worker_document_upload_idempotency")) + .containsEntry("worker_link_id", new ColumnSpec("uuid", false)) + .containsEntry("stored_file_id", new ColumnSpec("uuid", false)) + .containsEntry("company_id", new ColumnSpec("uuid", false)); assertThat(columnSpecs(connection, "workflow_case")) .containsEntry("case_id", new ColumnSpec("uuid", false)) .containsEntry("company_id", new ColumnSpec("uuid", false)) @@ -249,7 +274,19 @@ private void assertSchemaContract(Connection connection) throws SQLException { "pk_workflow_case", "uq_workflow_case_id_company", "fk_workflow_case_worker_company", - "fk_workflow_case_created_by_company" + "fk_workflow_case_created_by_company", + "uq_task_id_worker_company", + "fk_worker_document_task_worker_company", + "uq_worker_link_id_company", + "fk_worker_link_replaces_company", + "uq_worker_response_id_company", + "fk_worker_response_link_company", + "uq_stored_file_id_company", + "uq_worker_response_upload_file_company", + "fk_worker_response_upload_response_company", + "fk_worker_response_upload_file_company", + "fk_worker_document_upload_idempotency_link_company", + "fk_worker_document_upload_idempotency_file_company" ); assertThat(indexNames(connection)) .contains( @@ -272,7 +309,10 @@ private void assertSchemaContract(Connection connection) throws SQLException { "idx_ai_question_run", "idx_ai_candidate_run", "idx_workflow_case_company_updated", - "idx_workflow_case_company_worker" + "idx_workflow_case_company_worker", + "idx_worker_response_upload_company", + "idx_worker_document_upload_idempotency_company", + "idx_worker_document_upload_idempotency_file_company" ); assertThat(policyNames(connection)) .containsExactlyInAnyOrder( @@ -300,7 +340,8 @@ private void assertSchemaContract(Connection connection) throws SQLException { "pl_workflow_case_tenant_isolation", "pl_worker_link_tenant_isolation", "pl_worker_response_tenant_isolation", - "pl_worker_response_upload_tenant_isolation" + "pl_worker_response_upload_tenant_isolation", + "pl_worker_document_upload_idempotency_tenant_isolation" ); assertThat(rlsEnabledTables(connection)).isEmpty(); assertThat(securityDefinerFunctionNames(connection)) @@ -383,6 +424,36 @@ INSERT INTO task ( CURRENT_TIMESTAMP, CURRENT_TIMESTAMP ) """.formatted(TASK_A, COMPANY_A, WORKER_A, "f".repeat(64), USER_A, USER_A)); + execute(connection, """ + INSERT INTO worker_link ( + worker_link_id, task_id, company_id, token_hash, expires_at, + status, conversation_status, issued_by, idempotency_key, + created_at, updated_at + ) VALUES + ( + '21000000-0000-0000-0000-000000000001', '%s', '%s', '%s', + CURRENT_TIMESTAMP + INTERVAL '1 day', 'ACTIVE', + 'WAITING_WORKER', '%s', 'active-link', + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ), + ( + '21000000-0000-0000-0000-000000000002', '%s', '%s', '%s', + CURRENT_TIMESTAMP + INTERVAL '1 day', 'REVOKED', + 'WAITING_WORKER', '%s', 'revoked-link', + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ), + ( + '21000000-0000-0000-0000-000000000003', '%s', '%s', '%s', + CURRENT_TIMESTAMP - INTERVAL '1 day', 'ACTIVE', + 'WAITING_WORKER', '%s', 'expired-link', + CURRENT_TIMESTAMP - INTERVAL '2 days', + CURRENT_TIMESTAMP - INTERVAL '2 days' + ) + """.formatted( + TASK_A, COMPANY_A, ACTIVE_WORKER_LINK_TOKEN_HASH, USER_A, + TASK_A, COMPANY_A, REVOKED_WORKER_LINK_TOKEN_HASH, USER_A, + TASK_A, COMPANY_A, EXPIRED_WORKER_LINK_TOKEN_HASH, USER_A + )); execute(connection, """ INSERT INTO approval_request ( approval_request_id, task_id, company_id, @@ -438,6 +509,26 @@ INSERT INTO event_consumption ( "SELECT public.bootstrap_company_id_by_refresh_token_hash(?)", "0".repeat(64) )).isNull(); + assertThat(queryNullableString( + connection, + "SELECT public.bootstrap_company_id_by_worker_link_token_hash(?)", + ACTIVE_WORKER_LINK_TOKEN_HASH + )).isEqualTo(COMPANY_A); + assertThat(queryNullableString( + connection, + "SELECT public.bootstrap_company_id_by_worker_link_token_hash(?)", + REVOKED_WORKER_LINK_TOKEN_HASH + )).isNull(); + assertThat(queryNullableString( + connection, + "SELECT public.bootstrap_company_id_by_worker_link_token_hash(?)", + EXPIRED_WORKER_LINK_TOKEN_HASH + )).isNull(); + assertThat(queryNullableString( + connection, + "SELECT public.bootstrap_company_id_by_worker_link_token_hash(?)", + "0".repeat(64) + )).isNull(); assertSqlState(connection, "23505", """ INSERT INTO user_account ( diff --git a/src/test/java/com/fowoco/server/V20TenantIntegrityMigrationTest.java b/src/test/java/com/fowoco/server/V20TenantIntegrityMigrationTest.java new file mode 100644 index 0000000..00f39a1 --- /dev/null +++ b/src/test/java/com/fowoco/server/V20TenantIntegrityMigrationTest.java @@ -0,0 +1,253 @@ +package com.fowoco.server; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.UUID; +import javax.sql.DataSource; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.ClassPathResource; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.jdbc.datasource.init.ScriptUtils; + +class V20TenantIntegrityMigrationTest { + + private static final String COMPANY_A = "10000000-0000-0000-0000-000000000001"; + private static final String COMPANY_B = "20000000-0000-0000-0000-000000000002"; + private static final String WORKER_A = "12000000-0000-0000-0000-000000000001"; + private static final String WORKER_A2 = "12000000-0000-0000-0000-000000000002"; + private static final String TASK_A = "13000000-0000-0000-0000-000000000001"; + private static final String LINK_A = "14000000-0000-0000-0000-000000000001"; + private static final String RESPONSE_A = "15000000-0000-0000-0000-000000000001"; + private static final String FILE_A = "16000000-0000-0000-0000-000000000001"; + private static final String FILE_B = "26000000-0000-0000-0000-000000000002"; + + @Test + void backfillsCompanyIdsBeforeEnforcingTenantAwareRelationships() throws SQLException { + try (Connection connection = dataSource().getConnection()) { + createPreV20Schema(connection); + insertBaseFixture(connection, FILE_A, COMPANY_A); + execute(connection, """ + INSERT INTO worker_response_upload (response_id, stored_file_id) + VALUES ('%s', '%s') + """.formatted(RESPONSE_A, FILE_A)); + execute(connection, """ + INSERT INTO worker_document_upload_idempotency ( + worker_link_id, client_request_id, stored_file_id + ) VALUES ('%s', 'upload-request-1', '%s') + """.formatted(LINK_A, FILE_A)); + execute(connection, workerDocumentInsert(WORKER_A, TASK_A)); + + applyV20(connection); + + assertThat(queryString( + connection, + "SELECT company_id FROM worker_response_upload WHERE response_id = '" + RESPONSE_A + "'" + )).isEqualTo(COMPANY_A); + assertThat(queryString( + connection, + "SELECT company_id FROM worker_document_upload_idempotency " + + "WHERE worker_link_id = '" + LINK_A + "'" + )).isEqualTo(COMPANY_A); + } + } + + @Test + void rejectsExistingCrossTenantStoredFileRelationship() throws SQLException { + try (Connection connection = dataSource().getConnection()) { + createPreV20Schema(connection); + insertBaseFixture(connection, FILE_B, COMPANY_B); + execute(connection, """ + INSERT INTO worker_document_upload_idempotency ( + worker_link_id, client_request_id, stored_file_id + ) VALUES ('%s', 'cross-tenant-upload', '%s') + """.formatted(LINK_A, FILE_B)); + + assertThatThrownBy(() -> applyV20(connection)) + .isInstanceOf(RuntimeException.class); + } + } + + @Test + void rejectsExistingTaskLinkedToAnotherWorker() throws SQLException { + try (Connection connection = dataSource().getConnection()) { + createPreV20Schema(connection); + insertBaseFixture(connection, FILE_A, COMPANY_A); + execute(connection, workerDocumentInsert(WORKER_A2, TASK_A)); + + assertThatThrownBy(() -> applyV20(connection)) + .isInstanceOf(RuntimeException.class); + } + } + + @Test + void preventsStoredFileFromBeingLinkedToMultipleResponses() throws SQLException { + try (Connection connection = dataSource().getConnection()) { + createPreV20Schema(connection); + insertBaseFixture(connection, FILE_A, COMPANY_A); + applyV20(connection); + + String secondResponseId = "15000000-0000-0000-0000-000000000002"; + execute(connection, """ + INSERT INTO worker_response ( + response_id, worker_link_id, company_id + ) VALUES ('%s', '%s', '%s') + """.formatted(secondResponseId, LINK_A, COMPANY_A)); + execute(connection, """ + INSERT INTO worker_response_upload ( + response_id, stored_file_id, company_id + ) VALUES ('%s', '%s', '%s') + """.formatted(RESPONSE_A, FILE_A, COMPANY_A)); + + assertThatThrownBy(() -> execute(connection, """ + INSERT INTO worker_response_upload ( + response_id, stored_file_id, company_id + ) VALUES ('%s', '%s', '%s') + """.formatted(secondResponseId, FILE_A, COMPANY_A))) + .isInstanceOf(SQLException.class); + } + } + + private void createPreV20Schema(Connection connection) { + String sql = """ + CREATE TABLE task ( + task_id UUID NOT NULL, + worker_id UUID NOT NULL, + company_id UUID NOT NULL, + CONSTRAINT pk_task PRIMARY KEY (task_id), + CONSTRAINT uq_task_id_company UNIQUE (task_id, company_id) + ); + + CREATE TABLE worker_document ( + worker_document_id UUID NOT NULL, + worker_id UUID NOT NULL, + company_id UUID NOT NULL, + task_id UUID, + CONSTRAINT pk_worker_document PRIMARY KEY (worker_document_id), + CONSTRAINT fk_worker_document_task_company + FOREIGN KEY (task_id, company_id) + REFERENCES task (task_id, company_id) ON DELETE RESTRICT + ); + + CREATE TABLE worker_link ( + worker_link_id UUID NOT NULL, + company_id UUID NOT NULL, + replaces_link_id UUID, + CONSTRAINT pk_worker_link PRIMARY KEY (worker_link_id), + CONSTRAINT fk_worker_link_replaces + FOREIGN KEY (replaces_link_id) + REFERENCES worker_link (worker_link_id) ON DELETE SET NULL + ); + + CREATE TABLE worker_response ( + response_id UUID NOT NULL, + worker_link_id UUID NOT NULL, + company_id UUID NOT NULL, + CONSTRAINT pk_worker_response PRIMARY KEY (response_id), + CONSTRAINT fk_worker_response_link + FOREIGN KEY (worker_link_id) + REFERENCES worker_link (worker_link_id) ON DELETE RESTRICT + ); + + CREATE TABLE stored_file ( + stored_file_id UUID NOT NULL, + company_id UUID NOT NULL, + CONSTRAINT pk_stored_file PRIMARY KEY (stored_file_id) + ); + + CREATE TABLE worker_response_upload ( + response_id UUID NOT NULL, + stored_file_id UUID NOT NULL, + CONSTRAINT pk_worker_response_upload PRIMARY KEY (response_id, stored_file_id), + CONSTRAINT fk_worker_response_upload_response + FOREIGN KEY (response_id) + REFERENCES worker_response (response_id) ON DELETE CASCADE, + CONSTRAINT fk_worker_response_upload_file + FOREIGN KEY (stored_file_id) + REFERENCES stored_file (stored_file_id) ON DELETE RESTRICT + ); + + CREATE TABLE worker_document_upload_idempotency ( + worker_link_id UUID NOT NULL, + client_request_id VARCHAR(100) NOT NULL, + stored_file_id UUID NOT NULL, + CONSTRAINT pk_worker_document_upload_idempotency + PRIMARY KEY (worker_link_id, client_request_id), + CONSTRAINT fk_worker_document_upload_idempotency_link + FOREIGN KEY (worker_link_id) + REFERENCES worker_link (worker_link_id) ON DELETE RESTRICT, + CONSTRAINT fk_worker_document_upload_idempotency_file + FOREIGN KEY (stored_file_id) + REFERENCES stored_file (stored_file_id) ON DELETE RESTRICT + ); + """; + ScriptUtils.executeSqlScript( + connection, + new ByteArrayResource(sql.getBytes(StandardCharsets.UTF_8)) + ); + } + + private void insertBaseFixture( + Connection connection, + String storedFileId, + String storedFileCompanyId + ) throws SQLException { + execute(connection, """ + INSERT INTO task (task_id, worker_id, company_id) + VALUES ('%s', '%s', '%s') + """.formatted(TASK_A, WORKER_A, COMPANY_A)); + execute(connection, """ + INSERT INTO stored_file (stored_file_id, company_id) + VALUES ('%s', '%s') + """.formatted(storedFileId, storedFileCompanyId)); + execute(connection, """ + INSERT INTO worker_link (worker_link_id, company_id) + VALUES ('%s', '%s') + """.formatted(LINK_A, COMPANY_A)); + execute(connection, """ + INSERT INTO worker_response (response_id, worker_link_id, company_id) + VALUES ('%s', '%s', '%s') + """.formatted(RESPONSE_A, LINK_A, COMPANY_A)); + } + + private String workerDocumentInsert(String workerId, String taskId) { + return """ + INSERT INTO worker_document ( + worker_document_id, worker_id, company_id, task_id + ) VALUES ('%s', '%s', '%s', '%s') + """.formatted(UUID.randomUUID(), workerId, COMPANY_A, taskId); + } + + private void applyV20(Connection connection) { + ScriptUtils.executeSqlScript( + connection, + new ClassPathResource("db/migration/V20__harden_tenant_integrity.sql") + ); + } + + private DataSource dataSource() { + String url = "jdbc:h2:mem:v20_" + UUID.randomUUID() + + ";MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH"; + return new DriverManagerDataSource(url, "sa", ""); + } + + private void execute(Connection connection, String sql) throws SQLException { + try (Statement statement = connection.createStatement()) { + statement.execute(sql); + } + } + + private String queryString(Connection connection, String sql) throws SQLException { + try (Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery(sql)) { + assertThat(resultSet.next()).isTrue(); + return resultSet.getString(1); + } + } +} diff --git a/src/test/java/com/fowoco/server/common/security/PostgreSqlRlsIsolationTest.java b/src/test/java/com/fowoco/server/common/security/PostgreSqlRlsIsolationTest.java index 29f2278..169504f 100644 --- a/src/test/java/com/fowoco/server/common/security/PostgreSqlRlsIsolationTest.java +++ b/src/test/java/com/fowoco/server/common/security/PostgreSqlRlsIsolationTest.java @@ -41,10 +41,20 @@ class PostgreSqlRlsIsolationTest { UUID.fromString("a5000000-0000-0000-0000-000000000001"); private static final UUID STORED_FILE_B = UUID.fromString("b5000000-0000-0000-0000-000000000002"); + private static final UUID STORED_FILE_B_UNLINKED = + UUID.fromString("b5000000-0000-0000-0000-000000000003"); private static final UUID DRAFT_A = UUID.fromString("a6000000-0000-0000-0000-000000000001"); private static final UUID DRAFT_B = UUID.fromString("b6000000-0000-0000-0000-000000000002"); + private static final UUID WORKER_LINK_A = + UUID.fromString("a7000000-0000-0000-0000-000000000001"); + private static final UUID WORKER_LINK_B = + UUID.fromString("b7000000-0000-0000-0000-000000000002"); + private static final UUID WORKER_RESPONSE_A = + UUID.fromString("a8000000-0000-0000-0000-000000000001"); + private static final UUID WORKER_RESPONSE_B = + UUID.fromString("b8000000-0000-0000-0000-000000000002"); private static final UUID CASE_A = UUID.fromString("a7000000-0000-0000-0000-000000000001"); private static final UUID CASE_B = @@ -120,7 +130,10 @@ private void prepareFixture( "GRANT SELECT, INSERT, UPDATE, DELETE " + "ON TABLE public.company, public.worker, " + "public.stored_file, public.document_request_draft, " - + "public.document_request_draft_type, public.workflow_case TO " + + "public.document_request_draft_type, public.workflow_case, " + + "public.worker_link, public.worker_response, " + + "public.worker_response_upload, " + + "public.worker_document_upload_idempotency TO " + quotedRole ); @@ -189,8 +202,14 @@ INSERT INTO stored_file ( ('%s', '%s', 'tenant-a.pdf', 'application/pdf', 1, 'RLS_TEST', 'rls-tenant-a', 'NOT_SCANNED'), ('%s', '%s', 'tenant-b.pdf', 'application/pdf', 1, - 'RLS_TEST', 'rls-tenant-b', 'NOT_SCANNED') - """.formatted(STORED_FILE_A, COMPANY_A, STORED_FILE_B, COMPANY_B)); + 'RLS_TEST', 'rls-tenant-b', 'NOT_SCANNED'), + ('%s', '%s', 'tenant-b-unlinked.pdf', 'application/pdf', 1, + 'RLS_TEST', 'rls-tenant-b-unlinked', 'NOT_SCANNED') + """.formatted( + STORED_FILE_A, COMPANY_A, + STORED_FILE_B, COMPANY_B, + STORED_FILE_B_UNLINKED, COMPANY_B + )); statement.execute(""" INSERT INTO document_request_draft ( draft_id, task_id, company_id, language, message, review_status @@ -204,6 +223,52 @@ INSERT INTO document_request_draft_type (draft_id, document_type) ('%s', 'PASSPORT_COPY'), ('%s', 'ARC') """.formatted(DRAFT_A, DRAFT_B)); + statement.execute(""" + INSERT INTO worker_link ( + worker_link_id, task_id, company_id, token_hash, expires_at, + status, conversation_status, issued_by, idempotency_key + ) VALUES + ('%s', '%s', '%s', repeat('c', 64), + CURRENT_TIMESTAMP + INTERVAL '1 day', 'ACTIVE', + 'WAITING_WORKER', '%s', 'rls-link-a'), + ('%s', '%s', '%s', repeat('d', 64), + CURRENT_TIMESTAMP + INTERVAL '1 day', 'ACTIVE', + 'WAITING_WORKER', '%s', 'rls-link-b') + """.formatted( + WORKER_LINK_A, TASK_A, COMPANY_A, USER_A, + WORKER_LINK_B, TASK_B, COMPANY_B, USER_B + )); + statement.execute(""" + INSERT INTO worker_response ( + response_id, worker_link_id, company_id, + response_type, idempotency_key + ) VALUES + ('%s', '%s', '%s', 'DOCUMENT_SUBMITTED', 'rls-response-a'), + ('%s', '%s', '%s', 'DOCUMENT_SUBMITTED', 'rls-response-b') + """.formatted( + WORKER_RESPONSE_A, WORKER_LINK_A, COMPANY_A, + WORKER_RESPONSE_B, WORKER_LINK_B, COMPANY_B + )); + statement.execute(""" + INSERT INTO worker_response_upload ( + response_id, stored_file_id, company_id + ) VALUES + ('%s', '%s', '%s'), + ('%s', '%s', '%s') + """.formatted( + WORKER_RESPONSE_A, STORED_FILE_A, COMPANY_A, + WORKER_RESPONSE_B, STORED_FILE_B, COMPANY_B + )); + statement.execute(""" + INSERT INTO worker_document_upload_idempotency ( + worker_link_id, company_id, client_request_id, stored_file_id + ) VALUES + ('%s', '%s', 'rls-upload-a', '%s'), + ('%s', '%s', 'rls-upload-b', '%s') + """.formatted( + WORKER_LINK_A, COMPANY_A, STORED_FILE_A, + WORKER_LINK_B, COMPANY_B, STORED_FILE_B + )); statement.execute("ALTER TABLE public.company ENABLE ROW LEVEL SECURITY"); statement.execute("ALTER TABLE public.worker ENABLE ROW LEVEL SECURITY"); @@ -213,6 +278,15 @@ INSERT INTO document_request_draft_type (draft_id, document_type) statement.execute( "ALTER TABLE public.document_request_draft_type ENABLE ROW LEVEL SECURITY" ); + statement.execute("ALTER TABLE public.worker_link ENABLE ROW LEVEL SECURITY"); + statement.execute("ALTER TABLE public.worker_response ENABLE ROW LEVEL SECURITY"); + statement.execute( + "ALTER TABLE public.worker_response_upload ENABLE ROW LEVEL SECURITY" + ); + statement.execute( + "ALTER TABLE public.worker_document_upload_idempotency " + + "ENABLE ROW LEVEL SECURITY" + ); } } @@ -224,6 +298,10 @@ private void assertMissingAndInvalidContextFailClosed(Connection connection) assertThat(tableCount(connection, "stored_file")).isZero(); assertThat(tableCount(connection, "document_request_draft")).isZero(); assertThat(tableCount(connection, "document_request_draft_type")).isZero(); + assertThat(tableCount(connection, "worker_link")).isZero(); + assertThat(tableCount(connection, "worker_response")).isZero(); + assertThat(tableCount(connection, "worker_response_upload")).isZero(); + assertThat(tableCount(connection, "worker_document_upload_idempotency")).isZero(); assertThat(tableCount(connection, "workflow_case")).isZero(); setTenantContext(connection, ""); @@ -256,10 +334,34 @@ private void assertTenantCrudIsolation(Connection connection) throws SQLExceptio "SELECT document_type FROM public.document_request_draft_type " + "ORDER BY document_type" )).containsExactly("PASSPORT_COPY"); + + assertThat(uuidValues( + connection, + "SELECT worker_link_id FROM public.worker_link ORDER BY worker_link_id" + )).containsExactly(WORKER_LINK_A); + + assertThat(uuidValues( + connection, + "SELECT response_id FROM public.worker_response ORDER BY response_id" + )).containsExactly(WORKER_RESPONSE_A); + + assertThat(uuidValues( + connection, + "SELECT stored_file_id FROM public.worker_response_upload " + + "ORDER BY stored_file_id" + )).containsExactly(STORED_FILE_A); + + assertThat(uuidValues( + connection, + "SELECT stored_file_id FROM public.worker_document_upload_idempotency " + + "ORDER BY stored_file_id" + )).containsExactly(STORED_FILE_A); + assertThat(uuidValues( connection, "SELECT case_id FROM public.workflow_case ORDER BY case_id" )).containsExactly(CASE_A); + assertThat(executeUpdate( connection, """ @@ -362,6 +464,31 @@ INSERT INTO document_request_draft_type (draft_id, document_type) VALUES ('%s', 'CONTRACT') """.formatted(DRAFT_B) ); + assertSqlState( + connection, + "42501", + """ + INSERT INTO worker_document_upload_idempotency ( + worker_link_id, company_id, client_request_id, stored_file_id + ) VALUES ( + '%s', '%s', 'forbidden-rls-upload-b', '%s' + ) + """.formatted(WORKER_LINK_B, COMPANY_B, STORED_FILE_B) + ); + assertSqlState( + connection, + "42501", + """ + INSERT INTO worker_response_upload ( + response_id, stored_file_id, company_id + ) VALUES ( + '%s', '%s', '%s' + ) + """.formatted( + WORKER_RESPONSE_B, + STORED_FILE_B_UNLINKED, + COMPANY_B + )); assertThat(executeUpdate( connection, @@ -417,6 +544,15 @@ private void assertCommittedContextDoesNotLeak(Connection connection) throws SQL private void restoreFixture(Connection connection, String runtimeRole) throws SQLException { try (Statement statement = connection.createStatement()) { + statement.execute( + "ALTER TABLE public.worker_document_upload_idempotency " + + "DISABLE ROW LEVEL SECURITY" + ); + statement.execute( + "ALTER TABLE public.worker_response_upload DISABLE ROW LEVEL SECURITY" + ); + statement.execute("ALTER TABLE public.worker_response DISABLE ROW LEVEL SECURITY"); + statement.execute("ALTER TABLE public.worker_link DISABLE ROW LEVEL SECURITY"); statement.execute( "ALTER TABLE public.document_request_draft_type DISABLE ROW LEVEL SECURITY" ); @@ -435,6 +571,22 @@ private void restoreFixture(Connection connection, String runtimeRole) throws SQ } private void deleteFixtureRows(Statement statement) throws SQLException { + statement.execute(""" + DELETE FROM worker_document_upload_idempotency + WHERE worker_link_id IN ('%s', '%s') + """.formatted(WORKER_LINK_A, WORKER_LINK_B)); + statement.execute(""" + DELETE FROM worker_response_upload + WHERE response_id IN ('%s', '%s') + """.formatted(WORKER_RESPONSE_A, WORKER_RESPONSE_B)); + statement.execute(""" + DELETE FROM worker_response + WHERE response_id IN ('%s', '%s') + """.formatted(WORKER_RESPONSE_A, WORKER_RESPONSE_B)); + statement.execute(""" + DELETE FROM worker_link + WHERE worker_link_id IN ('%s', '%s') + """.formatted(WORKER_LINK_A, WORKER_LINK_B)); statement.execute(""" DELETE FROM document_request_draft_type WHERE draft_id IN ('%s', '%s') @@ -445,9 +597,9 @@ WHERE draft_id IN ('%s', '%s') """.formatted(DRAFT_A, DRAFT_B)); statement.execute(""" DELETE FROM stored_file - WHERE stored_file_id IN ('%s', '%s') + WHERE stored_file_id IN ('%s', '%s', '%s') OR storage_key = 'rls-forbidden-b' - """.formatted(STORED_FILE_A, STORED_FILE_B)); + """.formatted(STORED_FILE_A, STORED_FILE_B, STORED_FILE_B_UNLINKED)); statement.execute(""" DELETE FROM task WHERE task_id IN ('%s', '%s') diff --git a/src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoFileFixtureInstallerTest.java b/src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoFileFixtureInstallerTest.java index 1b7bd4c..c11c621 100644 --- a/src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoFileFixtureInstallerTest.java +++ b/src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoFileFixtureInstallerTest.java @@ -1,8 +1,6 @@ package com.fowoco.server.demo.infrastructure.seed; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.StoredFileSeed; import java.nio.file.Files; import java.nio.file.Path; @@ -17,7 +15,7 @@ class DemoFileFixtureInstallerTest { Path storageRoot; @Test - void installsReusesAndRejectsConflictingFixtureContent() throws Exception { + void installsReusesAndRefreshesChangedFixtureContent() throws Exception { UUID fileId = UUID.fromString("94800000-0000-0000-0000-000000000001"); StoredFileSeed seed = new StoredFileSeed( fileId, @@ -45,8 +43,15 @@ void installsReusesAndRejectsConflictingFixtureContent() throws Exception { conflicting[conflicting.length - 1] ^= 1; Files.write(installed, conflicting); assertThat(Files.size(installed)).isEqualTo(expected.length); - assertThatThrownBy(() -> installer.install(seed)) - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining(seed.storageKey()); + + installer.install(seed); + + assertThat(Files.readAllBytes(installed)).isEqualTo(expected); + + Files.write(installed, new byte[]{1}); + + installer.install(seed); + + assertThat(Files.readAllBytes(installed)).isEqualTo(expected); } } diff --git a/src/test/java/com/fowoco/server/worker/WorkerDocumentSecurityIntegrationTest.java b/src/test/java/com/fowoco/server/worker/WorkerDocumentSecurityIntegrationTest.java index 48c87c3..e52fe64 100644 --- a/src/test/java/com/fowoco/server/worker/WorkerDocumentSecurityIntegrationTest.java +++ b/src/test/java/com/fowoco/server/worker/WorkerDocumentSecurityIntegrationTest.java @@ -175,6 +175,56 @@ void listDocumentsFiltersByTaskId() throws Exception { assertThat(ids).doesNotContain(documentIdWithoutTask); } + @Test + void registerRejectsTaskOwnedByAnotherWorkerInSameCompany() throws Exception { + String accessToken = accessToken(login(HR_A_EMAIL)); + String anotherWorkerId = registerWorker(accessToken, "다른 근로자"); + String anotherWorkerTaskId = createTask(accessToken, anotherWorkerId); + + HttpResponse response = registerDocumentRequest( + accessToken, + workerIdInCompanyA, + anotherWorkerTaskId + ); + + assertThat(response.statusCode()).isEqualTo(422); + assertThat(JsonPath.read(response.body(), "$.code")) + .isEqualTo("WORKER_DOCUMENT_TASK_WORKER_MISMATCH"); + } + + @Test + void registerReturnsNotFoundForTaskFromAnotherCompany() throws Exception { + String companyAToken = accessToken(login(HR_A_EMAIL)); + String companyBToken = accessToken(login(HR_B_EMAIL)); + String companyBWorkerId = registerWorker(companyBToken, "다른 사업장 근로자"); + String companyBTaskId = createTask(companyBToken, companyBWorkerId); + + HttpResponse response = registerDocumentRequest( + companyAToken, + workerIdInCompanyA, + companyBTaskId + ); + + assertThat(response.statusCode()).isEqualTo(404); + assertThat(JsonPath.read(response.body(), "$.code")) + .isEqualTo("TASK_NOT_FOUND"); + } + + @Test + void registerReturnsNotFoundForUnknownTask() throws Exception { + String accessToken = accessToken(login(HR_A_EMAIL)); + + HttpResponse response = registerDocumentRequest( + accessToken, + workerIdInCompanyA, + "70000000-0000-0000-0000-000000000099" + ); + + assertThat(response.statusCode()).isEqualTo(404); + assertThat(JsonPath.read(response.body(), "$.code")) + .isEqualTo("TASK_NOT_FOUND"); + } + @Test void documentFromAnotherCompanyIsReturnedAsNotFoundOnPatch() throws Exception { String companyAToken = accessToken(login(HR_A_EMAIL)); @@ -235,16 +285,24 @@ private String registerDocument(String accessToken, String workerId) throws Exce } private String registerDocument(String accessToken, String workerId, String taskId) throws Exception { + HttpResponse response = registerDocumentRequest(accessToken, workerId, taskId); + assertThat(response.statusCode()).isEqualTo(201); + return JsonPath.read(response.body(), "$.worker_document_id"); + } + + private HttpResponse registerDocumentRequest( + String accessToken, + String workerId, + String taskId + ) throws Exception { String body = """ {"document_type": "PASSPORT_COPY", "submission_status": "MISSING", "task_id": %s} """.formatted(taskId == null ? "null" : "\"" + taskId + "\""); - HttpResponse response = postJson( + return postJson( "/api/v1/workers/" + workerId + "/documents", body, accessToken ); - assertThat(response.statusCode()).isEqualTo(201); - return JsonPath.read(response.body(), "$.worker_document_id"); } private String createTask(String accessToken, String workerId) throws Exception { diff --git a/src/test/java/com/fowoco/server/workerlink/application/WorkerLinkServiceTest.java b/src/test/java/com/fowoco/server/workerlink/application/WorkerLinkServiceTest.java new file mode 100644 index 0000000..9fbf199 --- /dev/null +++ b/src/test/java/com/fowoco/server/workerlink/application/WorkerLinkServiceTest.java @@ -0,0 +1,75 @@ +package com.fowoco.server.workerlink.application; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.fowoco.server.approval.application.port.ApprovalRequestRepository; +import com.fowoco.server.auth.application.ActorContext; +import com.fowoco.server.auth.domain.UserRole; +import com.fowoco.server.common.error.ApiException; +import com.fowoco.server.common.id.UuidGenerator; +import com.fowoco.server.common.security.TenantDatabaseContext; +import com.fowoco.server.task.application.port.TaskRepository; +import com.fowoco.server.workerlink.application.port.WorkerLinkGenerator; +import com.fowoco.server.workerlink.application.port.WorkerLinkRepository; +import com.fowoco.server.workerlink.infrastructure.security.WorkerLinkHasher; +import java.time.Clock; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; + +class WorkerLinkServiceTest { + + private static final UUID TASK_ID = UUID.fromString( + "10000000-0000-0000-0000-000000000001" + ); + private static final UUID COMPANY_ID = UUID.fromString( + "20000000-0000-0000-0000-000000000002" + ); + private static final UUID ACTOR_ID = UUID.fromString( + "30000000-0000-0000-0000-000000000003" + ); + + @Test + void bindsActorTenantBeforeFirstRepositoryAccess() { + TaskRepository taskRepository = mock(TaskRepository.class); + ApprovalRequestRepository approvalRepository = mock(ApprovalRequestRepository.class); + WorkerLinkRepository workerLinkRepository = mock(WorkerLinkRepository.class); + TenantDatabaseContext tenantDatabaseContext = mock(TenantDatabaseContext.class); + when(taskRepository.findByIdAndCompanyId(TASK_ID, COMPANY_ID)) + .thenReturn(Optional.empty()); + + WorkerLinkService service = new WorkerLinkService( + taskRepository, + approvalRepository, + workerLinkRepository, + mock(WorkerLinkGenerator.class), + mock(WorkerLinkHasher.class), + tenantDatabaseContext, + mock(UuidGenerator.class), + mock(Clock.class) + ); + WorkerLinkIssueCommand command = new WorkerLinkIssueCommand( + TASK_ID, + null, + false, + "worker-link-issue-1" + ); + ActorContext actor = new ActorContext( + ACTOR_ID, + COMPANY_ID, + Set.of(UserRole.HR) + ); + + assertThatThrownBy(() -> service.issue(command, actor)) + .isInstanceOf(ApiException.class); + + InOrder order = inOrder(tenantDatabaseContext, taskRepository); + order.verify(tenantDatabaseContext).setCompanyIdForCurrentTransaction(COMPANY_ID); + order.verify(taskRepository).findByIdAndCompanyId(TASK_ID, COMPANY_ID); + } +} diff --git a/src/test/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerResponseRepositoryTest.java b/src/test/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerResponseRepositoryTest.java new file mode 100644 index 0000000..2583c83 --- /dev/null +++ b/src/test/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerResponseRepositoryTest.java @@ -0,0 +1,32 @@ +package com.fowoco.server.workerlink.infrastructure.persistence; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.sql.SQLException; +import org.junit.jupiter.api.Test; + +class JpaWorkerResponseRepositoryTest { + + @Test + void identifiesOnlyTheUploadFileUniqueConstraint() { + RuntimeException uploadAlreadyLinked = new RuntimeException(new SQLException( + "duplicate key violates constraint uq_worker_response_upload_file_company", + "23505" + )); + RuntimeException unrelatedUniqueViolation = new RuntimeException(new SQLException( + "duplicate key violates constraint uq_worker_response_idempotency", + "23505" + )); + RuntimeException uploadForeignKeyViolation = new RuntimeException(new SQLException( + "violates constraint uq_worker_response_upload_file_company", + "23503" + )); + + assertThat(JpaWorkerResponseRepository.isUniqueUploadFileViolation(uploadAlreadyLinked)) + .isTrue(); + assertThat(JpaWorkerResponseRepository.isUniqueUploadFileViolation(unrelatedUniqueViolation)) + .isFalse(); + assertThat(JpaWorkerResponseRepository.isUniqueUploadFileViolation(uploadForeignKeyViolation)) + .isFalse(); + } +}