diff --git a/src/main/java/com/fowoco/server/approval/application/ApprovalService.java b/src/main/java/com/fowoco/server/approval/application/ApprovalService.java index ccf6920c..c0ec5193 100644 --- a/src/main/java/com/fowoco/server/approval/application/ApprovalService.java +++ b/src/main/java/com/fowoco/server/approval/application/ApprovalService.java @@ -6,6 +6,7 @@ import com.fowoco.server.approval.application.port.ExternalSubmissionRepository; import com.fowoco.server.approval.domain.ApprovalRequest; import com.fowoco.server.approval.domain.Evidence; +import com.fowoco.server.approval.domain.EvidenceType; import com.fowoco.server.approval.domain.ExternalSubmission; import com.fowoco.server.audit.application.port.AuditEventRepository; import com.fowoco.server.audit.domain.ActorType; @@ -16,9 +17,12 @@ 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.error.ErrorCode; import com.fowoco.server.common.id.UuidGenerator; import com.fowoco.server.common.security.TenantDatabaseContext; import com.fowoco.server.common.web.RequestMetadata; +import com.fowoco.server.settings.application.port.CompanySettingsRepository; +import com.fowoco.server.settings.domain.ApprovalPolicy; import com.fowoco.server.task.application.error.TaskErrorCode; import com.fowoco.server.task.application.TaskReadinessChecker; import com.fowoco.server.task.application.port.TaskRepository; @@ -31,6 +35,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.UUID; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -46,6 +51,7 @@ public class ApprovalService implements ApprovalControlPort { private final TaskTransitionRecorder transitionRecorder; private final TaskReadinessChecker taskReadinessChecker; private final ApprovalRequestRepository approvalRepository; + private final CompanySettingsRepository companySettingsRepository; private final ExternalSubmissionRepository externalSubmissionRepository; private final EvidenceRepository evidenceRepository; private final AuditEventRepository auditRepository; @@ -60,6 +66,7 @@ public ApprovalService( TaskTransitionRecorder transitionRecorder, TaskReadinessChecker taskReadinessChecker, ApprovalRequestRepository approvalRepository, + CompanySettingsRepository companySettingsRepository, ExternalSubmissionRepository externalSubmissionRepository, EvidenceRepository evidenceRepository, AuditEventRepository auditRepository, @@ -73,6 +80,7 @@ public ApprovalService( this.transitionRecorder = transitionRecorder; this.taskReadinessChecker = taskReadinessChecker; this.approvalRepository = approvalRepository; + this.companySettingsRepository = companySettingsRepository; this.externalSubmissionRepository = externalSubmissionRepository; this.evidenceRepository = evidenceRepository; this.auditRepository = auditRepository; @@ -147,7 +155,7 @@ public ApprovalResult approve( RequestMetadata metadata ) { bindTenant(actor); - actorAuthorizer.requireHrWrite(actor); + requireApprovalDecisionPermission(actor); Task task = requireTask(taskId, actor.companyId()); requireTaskVersion(task, command.expectedVersion()); ApprovalRequest approval = requirePendingApproval(taskId, actor.companyId()); @@ -185,7 +193,7 @@ public ApprovalResult reject( RequestMetadata metadata ) { bindTenant(actor); - actorAuthorizer.requireHrWrite(actor); + requireApprovalDecisionPermission(actor); Task task = requireTask(taskId, actor.companyId()); requireTaskVersion(task, command.expectedVersion()); ApprovalRequest approval = requirePendingApproval(taskId, actor.companyId()); @@ -321,7 +329,7 @@ public TaskActionResult complete( task.contentRevision(), task.criticalFingerprint() ); - boolean evidencePresent = evidenceRepository.existsByTaskIdAndCompanyId(taskId, actor.companyId()); + boolean evidencePresent = hasRequiredEvidence(task); Instant now = Instant.now(clock); TaskStatus previous = task.complete( approved, @@ -494,6 +502,39 @@ private List invalidateActiveApprovals( return active; } + private void requireApprovalDecisionPermission(ActorContext actor) { + actorAuthorizer.requireHrWrite(actor); + ApprovalPolicy approvalPolicy = companySettingsRepository + .findByCompanyId(actor.companyId()) + .orElseThrow(() -> new IllegalStateException( + "Persisted company settings are missing for company " + + actor.companyId() + )) + .approvalPolicy(); + if (actor.roles().stream().noneMatch(approvalPolicy::permits)) { + throw new ApiException(ErrorCode.ACCESS_DENIED); + } + } + + private boolean hasRequiredEvidence(Task task) { + Set recordedTypes = evidenceRepository.findTypesByTaskIdAndCompanyId( + task.taskId(), + task.companyId() + ); + if (recordedTypes.isEmpty()) { + return false; + } + Set additionalRequiredTypes = companySettingsRepository + .findByCompanyId(task.companyId()) + .orElseThrow(() -> new IllegalStateException( + "Persisted company settings are missing for company " + + task.companyId() + )) + .evidenceRules() + .getOrDefault(task.taskType(), Set.of()); + return recordedTypes.containsAll(additionalRequiredTypes); + } + private Task requireTask(UUID taskId, UUID companyId) { return taskRepository.findByIdAndCompanyId(taskId, companyId) .orElseThrow(() -> new ApiException(TaskErrorCode.TASK_NOT_FOUND)); diff --git a/src/main/java/com/fowoco/server/approval/application/port/EvidenceRepository.java b/src/main/java/com/fowoco/server/approval/application/port/EvidenceRepository.java index 2b5befb9..0deb9c25 100644 --- a/src/main/java/com/fowoco/server/approval/application/port/EvidenceRepository.java +++ b/src/main/java/com/fowoco/server/approval/application/port/EvidenceRepository.java @@ -1,11 +1,13 @@ package com.fowoco.server.approval.application.port; import com.fowoco.server.approval.domain.Evidence; +import com.fowoco.server.approval.domain.EvidenceType; +import java.util.Set; import java.util.UUID; public interface EvidenceRepository { Evidence save(Evidence evidence); - boolean existsByTaskIdAndCompanyId(UUID taskId, UUID companyId); + Set findTypesByTaskIdAndCompanyId(UUID taskId, UUID companyId); } diff --git a/src/main/java/com/fowoco/server/approval/infrastructure/persistence/JpaEvidenceRepository.java b/src/main/java/com/fowoco/server/approval/infrastructure/persistence/JpaEvidenceRepository.java index c3cf8502..e245cf65 100644 --- a/src/main/java/com/fowoco/server/approval/infrastructure/persistence/JpaEvidenceRepository.java +++ b/src/main/java/com/fowoco/server/approval/infrastructure/persistence/JpaEvidenceRepository.java @@ -2,6 +2,8 @@ import com.fowoco.server.approval.application.port.EvidenceRepository; import com.fowoco.server.approval.domain.Evidence; +import com.fowoco.server.approval.domain.EvidenceType; +import java.util.Set; import java.util.UUID; import org.springframework.stereotype.Repository; @@ -20,7 +22,7 @@ public Evidence save(Evidence evidence) { } @Override - public boolean existsByTaskIdAndCompanyId(UUID taskId, UUID companyId) { - return repository.existsByTaskIdAndCompanyId(taskId, companyId); + public Set findTypesByTaskIdAndCompanyId(UUID taskId, UUID companyId) { + return Set.copyOf(repository.findTypesByTaskIdAndCompanyId(taskId, companyId)); } } diff --git a/src/main/java/com/fowoco/server/approval/infrastructure/persistence/SpringDataEvidenceJpaRepository.java b/src/main/java/com/fowoco/server/approval/infrastructure/persistence/SpringDataEvidenceJpaRepository.java index bd9fb9d5..48cecfdf 100644 --- a/src/main/java/com/fowoco/server/approval/infrastructure/persistence/SpringDataEvidenceJpaRepository.java +++ b/src/main/java/com/fowoco/server/approval/infrastructure/persistence/SpringDataEvidenceJpaRepository.java @@ -1,9 +1,22 @@ package com.fowoco.server.approval.infrastructure.persistence; +import com.fowoco.server.approval.domain.EvidenceType; +import java.util.Set; 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; interface SpringDataEvidenceJpaRepository extends JpaRepository { - boolean existsByTaskIdAndCompanyId(UUID taskId, UUID companyId); + @Query(""" + SELECT DISTINCT evidence.evidenceType + FROM EvidenceJpaEntity evidence + WHERE evidence.taskId = :taskId + AND evidence.companyId = :companyId + """) + Set findTypesByTaskIdAndCompanyId( + @Param("taskId") UUID taskId, + @Param("companyId") UUID companyId + ); } diff --git a/src/main/java/com/fowoco/server/audit/api/AuditController.java b/src/main/java/com/fowoco/server/audit/api/AuditController.java index 9871c1aa..d9cfc027 100644 --- a/src/main/java/com/fowoco/server/audit/api/AuditController.java +++ b/src/main/java/com/fowoco/server/audit/api/AuditController.java @@ -70,7 +70,7 @@ public List taskActivities(@PathVariable UUID taskId) { @Operation( operationId = "searchAuditEvents", summary = "사업장 감사 이벤트 검색", - description = "ADMIN만 자신의 사업장 범위에서 필터와 불투명 cursor로 검색할 수 있습니다." + description = "회사 audit_visibility 정책이 허용하는 ADMIN 또는 HR이 자신의 사업장 범위에서 검색합니다." ) @ApiResponses({ @ApiResponse(responseCode = "200", description = "감사 이벤트 페이지"), @@ -78,7 +78,7 @@ public List taskActivities(@PathVariable UUID taskId) { @ApiResponse(responseCode = "403", ref = "#/components/responses/Forbidden") }) @GetMapping(path = "/audit-events", produces = MediaType.APPLICATION_JSON_VALUE) - @PreAuthorize("hasRole('ADMIN')") + @PreAuthorize("hasAnyRole('ADMIN', 'HR')") public AuditPageResponse search( @RequestParam(name = "actor_type", required = false) ActorType actorType, @RequestParam(required = false) AuditAction action, diff --git a/src/main/java/com/fowoco/server/audit/application/AuditQueryService.java b/src/main/java/com/fowoco/server/audit/application/AuditQueryService.java index b180632a..09bc1f1b 100644 --- a/src/main/java/com/fowoco/server/audit/application/AuditQueryService.java +++ b/src/main/java/com/fowoco/server/audit/application/AuditQueryService.java @@ -12,6 +12,8 @@ import com.fowoco.server.common.error.ApiException; import com.fowoco.server.common.error.ErrorCode; import com.fowoco.server.common.security.TenantDatabaseContext; +import com.fowoco.server.settings.application.port.CompanySettingsRepository; +import com.fowoco.server.settings.domain.AuditVisibility; import com.fowoco.server.task.application.error.TaskErrorCode; import com.fowoco.server.task.application.port.TaskRepository; import java.time.Instant; @@ -29,6 +31,7 @@ public class AuditQueryService { private final TenantDatabaseContext tenantDatabaseContext; private final TaskRepository taskRepository; private final AuditEventRepository auditRepository; + private final CompanySettingsRepository companySettingsRepository; private final AuditCursorCodec cursorCodec; public AuditQueryService( @@ -36,12 +39,14 @@ public AuditQueryService( TenantDatabaseContext tenantDatabaseContext, TaskRepository taskRepository, AuditEventRepository auditRepository, + CompanySettingsRepository companySettingsRepository, AuditCursorCodec cursorCodec ) { this.actorAuthorizer = actorAuthorizer; this.tenantDatabaseContext = tenantDatabaseContext; this.taskRepository = taskRepository; this.auditRepository = auditRepository; + this.companySettingsRepository = companySettingsRepository; this.cursorCodec = cursorCodec; } @@ -70,7 +75,7 @@ public AuditPageResult search( ActorContext actor ) { bindTenant(actor); - actorAuthorizer.requireAnyRole(actor, UserRole.ADMIN); + requireAuditSearchPermission(actor); if (createdFrom != null && createdTo != null && createdFrom.isAfter(createdTo)) { throw new ApiException(ErrorCode.INVALID_REQUEST); } @@ -102,6 +107,20 @@ private String normalizeTraceId(String traceId) { return traceId == null || traceId.isBlank() ? null : traceId.trim(); } + private void requireAuditSearchPermission(ActorContext actor) { + actorAuthorizer.requireAnyRole(actor, UserRole.ADMIN, UserRole.HR); + AuditVisibility visibility = companySettingsRepository + .findByCompanyId(actor.companyId()) + .orElseThrow(() -> new IllegalStateException( + "Persisted company settings are missing for company " + + actor.companyId() + )) + .auditVisibility(); + if (actor.roles().stream().noneMatch(visibility::permits)) { + throw new ApiException(ErrorCode.ACCESS_DENIED); + } + } + private void bindTenant(ActorContext actor) { tenantDatabaseContext.setCompanyIdForCurrentTransaction(actor.companyId()); } diff --git a/src/main/java/com/fowoco/server/audit/domain/AuditAction.java b/src/main/java/com/fowoco/server/audit/domain/AuditAction.java index daebd142..446d703e 100644 --- a/src/main/java/com/fowoco/server/audit/domain/AuditAction.java +++ b/src/main/java/com/fowoco/server/audit/domain/AuditAction.java @@ -33,6 +33,7 @@ public enum AuditAction { WORKER_IMPORT_ROWS_UPDATED, WORKER_IMPORT_COMMITTED, WORKER_IMPORT_RETRIED, + SETTINGS_UPDATED, DOCUMENT_OCR_REQUESTED, DOCUMENT_OCR_COMPLETED, DOCUMENT_OCR_FAILED, diff --git a/src/main/java/com/fowoco/server/audit/domain/AuditTargetType.java b/src/main/java/com/fowoco/server/audit/domain/AuditTargetType.java index 90b2f379..62f983ff 100644 --- a/src/main/java/com/fowoco/server/audit/domain/AuditTargetType.java +++ b/src/main/java/com/fowoco/server/audit/domain/AuditTargetType.java @@ -13,5 +13,6 @@ public enum AuditTargetType { WORKER_LINK, USER_ACCOUNT, WORKER_IMPORT, + COMPANY_SETTINGS, DOCUMENT_OCR_RUN } diff --git a/src/main/java/com/fowoco/server/auth/application/CompanyMemberAccount.java b/src/main/java/com/fowoco/server/auth/application/CompanyMemberAccount.java new file mode 100644 index 00000000..40996f77 --- /dev/null +++ b/src/main/java/com/fowoco/server/auth/application/CompanyMemberAccount.java @@ -0,0 +1,28 @@ +package com.fowoco.server.auth.application; + +import com.fowoco.server.auth.domain.AccountStatus; +import com.fowoco.server.auth.domain.UserRole; +import java.util.Objects; +import java.util.UUID; + +public record CompanyMemberAccount( + UUID userId, + String displayName, + UserRole role, + AccountStatus status +) { + + public CompanyMemberAccount { + Objects.requireNonNull(userId, "userId must not be null"); + if (displayName == null || displayName.isBlank()) { + throw new IllegalArgumentException("displayName must not be blank"); + } + displayName = displayName.strip(); + Objects.requireNonNull(role, "role must not be null"); + Objects.requireNonNull(status, "status must not be null"); + } + + public boolean active() { + return status == AccountStatus.ACTIVE; + } +} diff --git a/src/main/java/com/fowoco/server/auth/application/SignupService.java b/src/main/java/com/fowoco/server/auth/application/SignupService.java index 506f4a23..1c914d05 100644 --- a/src/main/java/com/fowoco/server/auth/application/SignupService.java +++ b/src/main/java/com/fowoco/server/auth/application/SignupService.java @@ -20,6 +20,7 @@ import com.fowoco.server.common.id.UuidGenerator; import com.fowoco.server.common.security.TenantDatabaseContext; import com.fowoco.server.company.application.port.CompanyRepository; +import com.fowoco.server.company.application.port.CompanySettingsProvisioner; import com.fowoco.server.company.domain.Company; import java.time.Clock; import java.time.Instant; @@ -32,6 +33,7 @@ public class SignupService { private final CompanyRepository companyRepository; + private final CompanySettingsProvisioner companySettingsProvisioner; private final UserAccountRepository userAccountRepository; private final UserAgreementConsentRepository consentRepository; private final AuthTenantBootstrap authTenantBootstrap; @@ -45,6 +47,7 @@ public class SignupService { public SignupService( CompanyRepository companyRepository, + CompanySettingsProvisioner companySettingsProvisioner, UserAccountRepository userAccountRepository, UserAgreementConsentRepository consentRepository, AuthTenantBootstrap authTenantBootstrap, @@ -57,6 +60,7 @@ public SignupService( Clock clock ) { this.companyRepository = companyRepository; + this.companySettingsProvisioner = companySettingsProvisioner; this.userAccountRepository = userAccountRepository; this.consentRepository = consentRepository; this.authTenantBootstrap = authTenantBootstrap; @@ -95,6 +99,7 @@ public SignupResult signup(SignupCommand command) { ); companyRepository.insert(company); + companySettingsProvisioner.provisionDefaults(company.companyId(), now); try { userAccountRepository.insert(initialAdmin); } catch (DataIntegrityViolationException exception) { diff --git a/src/main/java/com/fowoco/server/auth/application/port/CompanyMemberDirectory.java b/src/main/java/com/fowoco/server/auth/application/port/CompanyMemberDirectory.java new file mode 100644 index 00000000..4287a4ea --- /dev/null +++ b/src/main/java/com/fowoco/server/auth/application/port/CompanyMemberDirectory.java @@ -0,0 +1,15 @@ +package com.fowoco.server.auth.application.port; + +import com.fowoco.server.auth.application.CompanyMemberAccount; +import com.fowoco.server.auth.domain.UserRole; +import java.util.List; +import java.util.UUID; + +public interface CompanyMemberDirectory { + + List findByCompanyId( + UUID companyId, + UserRole role, + boolean activeOnly + ); +} diff --git a/src/main/java/com/fowoco/server/auth/infrastructure/persistence/JpaCompanyMemberDirectory.java b/src/main/java/com/fowoco/server/auth/infrastructure/persistence/JpaCompanyMemberDirectory.java new file mode 100644 index 00000000..520284d8 --- /dev/null +++ b/src/main/java/com/fowoco/server/auth/infrastructure/persistence/JpaCompanyMemberDirectory.java @@ -0,0 +1,57 @@ +package com.fowoco.server.auth.infrastructure.persistence; + +import com.fowoco.server.auth.application.CompanyMemberAccount; +import com.fowoco.server.auth.application.port.CompanyMemberDirectory; +import com.fowoco.server.auth.domain.AccountStatus; +import com.fowoco.server.auth.domain.UserRole; +import jakarta.persistence.EntityManager; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import org.springframework.stereotype.Repository; + +@Repository +public class JpaCompanyMemberDirectory implements CompanyMemberDirectory { + + private final EntityManager entityManager; + + public JpaCompanyMemberDirectory(EntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + public List findByCompanyId( + UUID companyId, + UserRole role, + boolean activeOnly + ) { + Objects.requireNonNull(companyId, "companyId must not be null"); + StringBuilder jpql = new StringBuilder(""" + select new com.fowoco.server.auth.application.CompanyMemberAccount( + account.userId, + account.displayName, + account.role, + account.status + ) + from UserAccountJpaEntity account + where account.companyId = :companyId + """); + if (role != null) { + jpql.append(" and account.role = :role"); + } + if (activeOnly) { + jpql.append(" and account.status = :activeStatus"); + } + jpql.append(" order by account.displayName asc, account.userId asc"); + + var query = entityManager.createQuery(jpql.toString(), CompanyMemberAccount.class) + .setParameter("companyId", companyId); + if (role != null) { + query.setParameter("role", role); + } + if (activeOnly) { + query.setParameter("activeStatus", AccountStatus.ACTIVE); + } + return List.copyOf(query.getResultList()); + } +} diff --git a/src/main/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedConfiguration.java b/src/main/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedConfiguration.java index 860764ba..63d99375 100644 --- a/src/main/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedConfiguration.java +++ b/src/main/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedConfiguration.java @@ -2,6 +2,7 @@ import com.fowoco.server.auth.application.port.UserAccountRepository; import com.fowoco.server.company.application.port.CompanyRepository; +import com.fowoco.server.company.application.port.CompanySettingsProvisioner; import java.time.Clock; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; @@ -18,6 +19,7 @@ public class DemoAuthSeedConfiguration { DemoAuthSeedRunner demoAuthSeedRunner( DemoAuthSeedProperties properties, CompanyRepository companyRepository, + CompanySettingsProvisioner companySettingsProvisioner, UserAccountRepository userAccountRepository, PasswordEncoder passwordEncoder, Clock clock @@ -25,6 +27,7 @@ DemoAuthSeedRunner demoAuthSeedRunner( return new DemoAuthSeedRunner( properties, companyRepository, + companySettingsProvisioner, userAccountRepository, passwordEncoder, clock diff --git a/src/main/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunner.java b/src/main/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunner.java index 23aa4267..566a783b 100644 --- a/src/main/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunner.java +++ b/src/main/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunner.java @@ -4,6 +4,7 @@ import com.fowoco.server.auth.domain.UserAccount; import com.fowoco.server.auth.domain.UserRole; import com.fowoco.server.company.application.port.CompanyRepository; +import com.fowoco.server.company.application.port.CompanySettingsProvisioner; import com.fowoco.server.company.domain.Company; import java.time.Clock; import java.time.Instant; @@ -53,6 +54,7 @@ class DemoAuthSeedRunner implements ApplicationRunner { private final DemoAuthSeedProperties properties; private final CompanyRepository companyRepository; + private final CompanySettingsProvisioner companySettingsProvisioner; private final UserAccountRepository userAccountRepository; private final PasswordEncoder passwordEncoder; private final Clock clock; @@ -60,6 +62,7 @@ class DemoAuthSeedRunner implements ApplicationRunner { DemoAuthSeedRunner( DemoAuthSeedProperties properties, CompanyRepository companyRepository, + CompanySettingsProvisioner companySettingsProvisioner, UserAccountRepository userAccountRepository, PasswordEncoder passwordEncoder, Clock clock @@ -69,6 +72,10 @@ class DemoAuthSeedRunner implements ApplicationRunner { companyRepository, "companyRepository must not be null" ); + this.companySettingsProvisioner = Objects.requireNonNull( + companySettingsProvisioner, + "companySettingsProvisioner must not be null" + ); this.userAccountRepository = Objects.requireNonNull( userAccountRepository, "userAccountRepository must not be null" @@ -137,6 +144,7 @@ private void ensureCompany(UUID companyId, String companyName, Instant now) { companyName, now )); + companySettingsProvisioner.provisionDefaults(companyId, now); return; } if (!existingCompany.orElseThrow().isActive()) { diff --git a/src/main/java/com/fowoco/server/company/application/port/CompanySettingsProvisioner.java b/src/main/java/com/fowoco/server/company/application/port/CompanySettingsProvisioner.java new file mode 100644 index 00000000..0d495781 --- /dev/null +++ b/src/main/java/com/fowoco/server/company/application/port/CompanySettingsProvisioner.java @@ -0,0 +1,9 @@ +package com.fowoco.server.company.application.port; + +import java.time.Instant; +import java.util.UUID; + +public interface CompanySettingsProvisioner { + + void provisionDefaults(UUID companyId, Instant now); +} diff --git a/src/main/java/com/fowoco/server/reliability/application/OutboxManualRetryService.java b/src/main/java/com/fowoco/server/reliability/application/OutboxManualRetryService.java index 80928119..81569abb 100644 --- a/src/main/java/com/fowoco/server/reliability/application/OutboxManualRetryService.java +++ b/src/main/java/com/fowoco/server/reliability/application/OutboxManualRetryService.java @@ -12,6 +12,7 @@ import com.fowoco.server.common.error.ErrorCode; import com.fowoco.server.common.id.UuidGenerator; import com.fowoco.server.common.security.TenantDatabaseContext; +import com.fowoco.server.common.time.DatabaseTimestamp; import com.fowoco.server.common.web.RequestMetadata; import com.fowoco.server.reliability.application.error.OutboxErrorCode; import com.fowoco.server.reliability.application.port.EventPublicationRepository; @@ -100,7 +101,7 @@ public OutboxManualRetryResult requestRetry( throw new ApiException(OutboxErrorCode.OUTBOX_EVENT_NOT_REVIEW_REQUIRED); } - Instant now = clock.instant(); + Instant now = DatabaseTimestamp.nowNotBefore(clock, publication.updatedAt()); int previousAttemptCount = publication.attemptCount(); publication.requestManualRetry(expectedVersion, now); EventPublication saved = publicationRepository.save(publication); diff --git a/src/main/java/com/fowoco/server/settings/api/CompanyMemberController.java b/src/main/java/com/fowoco/server/settings/api/CompanyMemberController.java new file mode 100644 index 00000000..c56b7f1a --- /dev/null +++ b/src/main/java/com/fowoco/server/settings/api/CompanyMemberController.java @@ -0,0 +1,100 @@ +package com.fowoco.server.settings.api; + +import com.fowoco.server.auth.application.ActorContext; +import com.fowoco.server.auth.application.port.ActorContextProvider; +import com.fowoco.server.auth.domain.UserRole; +import com.fowoco.server.settings.application.CompanyMemberService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.MediaType; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Company Members", description = "같은 사업장의 선택 가능한 구성원 조회") +@SecurityRequirement(name = "bearerAuth") +@RestController +@RequestMapping("/api/v1/company-members") +public class CompanyMemberController { + + private final CompanyMemberService companyMemberService; + private final ActorContextProvider actorContextProvider; + + public CompanyMemberController( + CompanyMemberService companyMemberService, + ActorContextProvider actorContextProvider + ) { + this.companyMemberService = companyMemberService; + this.actorContextProvider = actorContextProvider; + } + + @Operation( + operationId = "listCompanyMembers", + summary = "사업장 구성원 목록 조회", + description = "같은 사업장의 구성원을 이름과 ID 순으로 반환합니다. " + + "VIEWER는 필터 사용과 역할·상태·승인 권한 조회가 제한됩니다." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "역할별 구성원 projection 조회 성공", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = CompanyMemberListResponse.class), + examples = { + @ExampleObject( + name = "adminOrHr", + value = """ + {"items":[{ + "user_id":"7e2722bb-3c72-4aa0-b37c-28931c4f8e53", + "display_name":"김인사", + "roles":["HR"], + "active":true, + "approval_permission":true + }]} + """ + ), + @ExampleObject( + name = "viewer", + value = """ + {"items":[{ + "user_id":"7e2722bb-3c72-4aa0-b37c-28931c4f8e53", + "display_name":"김인사" + }]} + """ + ) + } + ) + ), + @ApiResponse(responseCode = "400", ref = "#/components/responses/BadRequest"), + @ApiResponse(responseCode = "401", ref = "#/components/responses/Unauthorized"), + @ApiResponse(responseCode = "403", ref = "#/components/responses/Forbidden") + }) + @PreAuthorize("hasAnyRole('ADMIN', 'HR', 'VIEWER')") + @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE) + public CompanyMemberListResponse findAll( + @Parameter(description = "ADMIN | HR | VIEWER. ADMIN/HR만 사용 가능") + @RequestParam(required = false) UserRole role, + @Parameter(description = "derived approval_permission 필터. ADMIN/HR만 사용 가능") + @RequestParam(name = "approval_capable", required = false) Boolean approvalCapable, + @Parameter(description = "true면 ACTIVE만, false면 상태 필터 없음") + @RequestParam(name = "active_only", defaultValue = "true") boolean activeOnly + ) { + ActorContext actor = actorContextProvider.requireCurrentActor(); + return CompanyMemberListResponse.from(companyMemberService.findAll( + actor, + role, + approvalCapable, + activeOnly + )); + } +} diff --git a/src/main/java/com/fowoco/server/settings/api/CompanyMemberItemResponse.java b/src/main/java/com/fowoco/server/settings/api/CompanyMemberItemResponse.java new file mode 100644 index 00000000..d8f6458b --- /dev/null +++ b/src/main/java/com/fowoco/server/settings/api/CompanyMemberItemResponse.java @@ -0,0 +1,14 @@ +package com.fowoco.server.settings.api; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema( + name = "CompanyMemberItemResponse", + oneOf = { + DetailedCompanyMemberResponse.class, + MinimalCompanyMemberResponse.class + } +) +public sealed interface CompanyMemberItemResponse + permits DetailedCompanyMemberResponse, MinimalCompanyMemberResponse { +} diff --git a/src/main/java/com/fowoco/server/settings/api/CompanyMemberListResponse.java b/src/main/java/com/fowoco/server/settings/api/CompanyMemberListResponse.java new file mode 100644 index 00000000..01a571f0 --- /dev/null +++ b/src/main/java/com/fowoco/server/settings/api/CompanyMemberListResponse.java @@ -0,0 +1,44 @@ +package com.fowoco.server.settings.api; + +import com.fowoco.server.settings.application.CompanyMemberView; +import com.fowoco.server.settings.application.DetailedCompanyMemberView; +import com.fowoco.server.settings.application.MinimalCompanyMemberView; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import java.util.List; + +@Schema(name = "CompanyMemberListResponse", description = "같은 사업장의 구성원 목록") +public record CompanyMemberListResponse( + @ArraySchema( + schema = @Schema(implementation = CompanyMemberItemResponse.class), + arraySchema = @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + ) + @NotNull + List items +) { + + public CompanyMemberListResponse { + items = List.copyOf(items); + } + + static CompanyMemberListResponse from(List members) { + return new CompanyMemberListResponse(members.stream() + .map(CompanyMemberListResponse::item) + .toList()); + } + + private static CompanyMemberItemResponse item(CompanyMemberView member) { + if (member instanceof DetailedCompanyMemberView detailed) { + return new DetailedCompanyMemberResponse( + detailed.userId(), + detailed.displayName(), + detailed.roles(), + detailed.active(), + detailed.approvalPermission() + ); + } + MinimalCompanyMemberView minimal = (MinimalCompanyMemberView) member; + return new MinimalCompanyMemberResponse(minimal.userId(), minimal.displayName()); + } +} diff --git a/src/main/java/com/fowoco/server/settings/api/CompanySettingsController.java b/src/main/java/com/fowoco/server/settings/api/CompanySettingsController.java new file mode 100644 index 00000000..4544dec0 --- /dev/null +++ b/src/main/java/com/fowoco/server/settings/api/CompanySettingsController.java @@ -0,0 +1,155 @@ +package com.fowoco.server.settings.api; + +import com.fowoco.server.auth.application.ActorContext; +import com.fowoco.server.auth.application.port.ActorContextProvider; +import com.fowoco.server.settings.application.CompanySettingsService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import com.fowoco.server.common.web.RequestMetadata; +import org.springframework.http.MediaType; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Company Settings", description = "사업장 공통 운영 정책 조회·수정") +@SecurityRequirement(name = "bearerAuth") +@RestController +@RequestMapping("/api/v1/settings") +public class CompanySettingsController { + + private final CompanySettingsService companySettingsService; + private final ActorContextProvider actorContextProvider; + + public CompanySettingsController( + CompanySettingsService companySettingsService, + ActorContextProvider actorContextProvider + ) { + this.companySettingsService = companySettingsService; + this.actorContextProvider = actorContextProvider; + } + + @Operation( + operationId = "getCompanySettings", + summary = "회사 설정 조회", + description = "ADMIN, HR, VIEWER에게 Secret과 개인정보가 없는 동일한 public 설정 DTO를 반환합니다." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "persisted 사업장 설정 조회 성공", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = CompanySettingsResponse.class), + examples = @ExampleObject( + name = "settings", + value = """ + { + "approval_policy": "ADMIN_OR_HR", + "link_expiry_hours": 72, + "evidence_rules": { + "RECONTRACT": ["DOCUMENT"], + "STAY_PERIOD_EXTENSION": ["OFFICIAL_RESULT"] + }, + "file_retention_days": 365, + "ai_log_retention_days": 90, + "audit_visibility": "ADMIN_ONLY", + "version": 0 + } + """ + ) + ) + ), + @ApiResponse(responseCode = "401", ref = "#/components/responses/Unauthorized"), + @ApiResponse(responseCode = "403", ref = "#/components/responses/Forbidden"), + @ApiResponse(responseCode = "500", ref = "#/components/responses/InternalServerError") + }) + @PreAuthorize("hasAnyRole('ADMIN', 'HR', 'VIEWER')") + @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE) + public CompanySettingsResponse get() { + ActorContext actor = actorContextProvider.requireCurrentActor(); + return CompanySettingsResponse.from(companySettingsService.get(actor)); + } + + @Operation( + operationId = "updateCompanySettings", + summary = "회사 설정 수정", + description = "ADMIN만 설정을 부분 수정할 수 있으며 expected_version으로 동시성을 제어합니다." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "수정 후 전체 회사 설정. no-op이면 version을 유지합니다.", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = CompanySettingsResponse.class), + examples = @ExampleObject( + name = "updatedSettings", + value = """ + { + "approval_policy": "ADMIN_ONLY", + "link_expiry_hours": 48, + "evidence_rules": { + "RECONTRACT": ["DOCUMENT"] + }, + "file_retention_days": 365, + "ai_log_retention_days": 90, + "audit_visibility": "ADMIN_AND_HR", + "version": 5 + } + """ + ) + ) + ), + @ApiResponse(responseCode = "400", ref = "#/components/responses/BadRequest"), + @ApiResponse(responseCode = "401", ref = "#/components/responses/Unauthorized"), + @ApiResponse(responseCode = "403", ref = "#/components/responses/Forbidden"), + @ApiResponse(responseCode = "409", ref = "#/components/responses/Conflict"), + @ApiResponse(responseCode = "500", ref = "#/components/responses/InternalServerError") + }) + @PreAuthorize("hasRole('ADMIN')") + @PatchMapping( + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE + ) + public CompanySettingsResponse update( + @io.swagger.v3.oas.annotations.parameters.RequestBody( + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = CompanySettingsPatchRequest.class), + examples = @ExampleObject( + name = "partialUpdate", + summary = "일부 설정과 낙관적 잠금 version 수정", + value = """ + { + "expected_version": 4, + "approval_policy": "ADMIN_ONLY", + "link_expiry_hours": 48, + "audit_visibility": "ADMIN_AND_HR" + } + """ + ) + ) + ) + @Valid @RequestBody CompanySettingsPatchRequest request, + HttpServletRequest servletRequest + ) { + ActorContext actor = actorContextProvider.requireCurrentActor(); + return CompanySettingsResponse.from(companySettingsService.update( + actor, + request.toCommand(), + RequestMetadata.from(servletRequest) + )); + } +} diff --git a/src/main/java/com/fowoco/server/settings/api/CompanySettingsPatchRequest.java b/src/main/java/com/fowoco/server/settings/api/CompanySettingsPatchRequest.java new file mode 100644 index 00000000..b1522798 --- /dev/null +++ b/src/main/java/com/fowoco/server/settings/api/CompanySettingsPatchRequest.java @@ -0,0 +1,194 @@ +package com.fowoco.server.settings.api; + +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.fowoco.server.approval.domain.EvidenceType; +import com.fowoco.server.settings.application.PatchField; +import com.fowoco.server.settings.application.UpdateCompanySettingsCommand; +import com.fowoco.server.settings.domain.ApprovalPolicy; +import com.fowoco.server.settings.domain.AuditVisibility; +import com.fowoco.server.settings.domain.CompanySettings; +import com.fowoco.server.task.domain.TaskType; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; +import java.util.EnumMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Schema( + name = "CompanySettingsPatchRequest", + description = "생략한 설정은 유지하며, 명시적 null과 정의되지 않은 필드는 거부합니다.", + additionalProperties = Schema.AdditionalPropertiesValue.FALSE +) +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public final class CompanySettingsPatchRequest { + + @NotNull + @PositiveOrZero + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, minimum = "0", example = "4") + private Long expectedVersion; + + @Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED, nullable = false) + private ApprovalPolicy approvalPolicy; + @JsonIgnore + @Schema(hidden = true) + private boolean approvalPolicyPresent; + + @Min(CompanySettings.MIN_LINK_EXPIRY_HOURS) + @Max(CompanySettings.MAX_LINK_EXPIRY_HOURS) + @Schema(minimum = "1", maximum = "168", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + private Long linkExpiryHours; + @JsonIgnore + @Schema(hidden = true) + private boolean linkExpiryHoursPresent; + + @Valid + @Schema( + requiredMode = Schema.RequiredMode.NOT_REQUIRED, + nullable = false, + description = "TaskType별 추가 필수 EvidenceType 목록이며 배열 내 중복은 허용하지 않습니다." + ) + private Map<@NotNull TaskType, @NotNull List<@NotNull EvidenceType>> evidenceRules; + @JsonIgnore + @Schema(hidden = true) + private boolean evidenceRulesPresent; + + @Min(CompanySettings.MIN_FILE_RETENTION_DAYS) + @Max(CompanySettings.MAX_FILE_RETENTION_DAYS) + @Schema(minimum = "30", maximum = "3650", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + private Integer fileRetentionDays; + @JsonIgnore + @Schema(hidden = true) + private boolean fileRetentionDaysPresent; + + @Min(CompanySettings.MIN_AI_LOG_RETENTION_DAYS) + @Max(CompanySettings.MAX_AI_LOG_RETENTION_DAYS) + @Schema(minimum = "7", maximum = "365", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + private Integer aiLogRetentionDays; + @JsonIgnore + @Schema(hidden = true) + private boolean aiLogRetentionDaysPresent; + + @Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED, nullable = false) + private AuditVisibility auditVisibility; + @JsonIgnore + @Schema(hidden = true) + private boolean auditVisibilityPresent; + + @JsonSetter(value = "expected_version", nulls = Nulls.FAIL) + @JsonProperty(value = "expected_version", required = true) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, minimum = "0", example = "4") + public void setExpectedVersion(Long expectedVersion) { + this.expectedVersion = expectedVersion; + } + + @JsonSetter(value = "approval_policy", nulls = Nulls.FAIL) + @JsonProperty("approval_policy") + @Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED, nullable = false) + public void setApprovalPolicy(ApprovalPolicy approvalPolicy) { + this.approvalPolicyPresent = true; + this.approvalPolicy = approvalPolicy; + } + + @JsonSetter(value = "link_expiry_hours", nulls = Nulls.FAIL) + @JsonProperty("link_expiry_hours") + @Schema(minimum = "1", maximum = "168", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + public void setLinkExpiryHours(Long linkExpiryHours) { + this.linkExpiryHoursPresent = true; + this.linkExpiryHours = linkExpiryHours; + } + + @JsonSetter(value = "evidence_rules", nulls = Nulls.FAIL) + @JsonProperty("evidence_rules") + @Schema( + requiredMode = Schema.RequiredMode.NOT_REQUIRED, + nullable = false, + description = "TaskType별 추가 필수 EvidenceType 목록이며 배열 내 중복은 허용하지 않습니다." + ) + public void setEvidenceRules(Map> evidenceRules) { + this.evidenceRulesPresent = true; + this.evidenceRules = evidenceRules; + } + + @AssertTrue(message = "evidence_rules 배열에는 같은 evidence type을 중복 지정할 수 없습니다.") + @JsonIgnore + @Schema(hidden = true) + public boolean isEvidenceRulesUnique() { + if (!evidenceRulesPresent || evidenceRules == null) { + return true; + } + return evidenceRules.values().stream() + .filter(evidenceTypes -> evidenceTypes != null) + .allMatch(evidenceTypes -> evidenceTypes.size() + == new HashSet<>(evidenceTypes).size()); + } + + @JsonSetter(value = "file_retention_days", nulls = Nulls.FAIL) + @JsonProperty("file_retention_days") + @Schema(minimum = "30", maximum = "3650", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + public void setFileRetentionDays(Integer fileRetentionDays) { + this.fileRetentionDaysPresent = true; + this.fileRetentionDays = fileRetentionDays; + } + + @JsonSetter(value = "ai_log_retention_days", nulls = Nulls.FAIL) + @JsonProperty("ai_log_retention_days") + @Schema(minimum = "7", maximum = "365", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + public void setAiLogRetentionDays(Integer aiLogRetentionDays) { + this.aiLogRetentionDaysPresent = true; + this.aiLogRetentionDays = aiLogRetentionDays; + } + + @JsonSetter(value = "audit_visibility", nulls = Nulls.FAIL) + @JsonProperty("audit_visibility") + @Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED, nullable = false) + public void setAuditVisibility(AuditVisibility auditVisibility) { + this.auditVisibilityPresent = true; + this.auditVisibility = auditVisibility; + } + + @JsonAnySetter + public void rejectUnknownField(String fieldName, Object value) { + throw new IllegalArgumentException("Unknown company settings field: " + fieldName); + } + + @JsonIgnore + public UpdateCompanySettingsCommand toCommand() { + return new UpdateCompanySettingsCommand( + expectedVersion, + field(approvalPolicyPresent, approvalPolicy), + field(linkExpiryHoursPresent, linkExpiryHours), + evidenceRulesField(), + field(fileRetentionDaysPresent, fileRetentionDays), + field(aiLogRetentionDaysPresent, aiLogRetentionDays), + field(auditVisibilityPresent, auditVisibility) + ); + } + + private PatchField>> evidenceRulesField() { + if (!evidenceRulesPresent) { + return PatchField.absent(); + } + EnumMap> converted = new EnumMap<>(TaskType.class); + evidenceRules.forEach((taskType, evidenceTypes) -> + converted.put(taskType, Set.copyOf(evidenceTypes)) + ); + return PatchField.of(converted); + } + + private PatchField field(boolean present, T value) { + return present ? PatchField.of(value) : PatchField.absent(); + } +} diff --git a/src/main/java/com/fowoco/server/settings/api/CompanySettingsResponse.java b/src/main/java/com/fowoco/server/settings/api/CompanySettingsResponse.java new file mode 100644 index 00000000..7d01debd --- /dev/null +++ b/src/main/java/com/fowoco/server/settings/api/CompanySettingsResponse.java @@ -0,0 +1,82 @@ +package com.fowoco.server.settings.api; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.fowoco.server.approval.domain.EvidenceType; +import com.fowoco.server.settings.domain.ApprovalPolicy; +import com.fowoco.server.settings.domain.AuditVisibility; +import com.fowoco.server.settings.domain.CompanySettings; +import com.fowoco.server.task.domain.TaskType; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.Map; +import java.util.Set; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +@Schema( + name = "CompanySettingsResponse", + description = "Secret과 개인정보를 제외한 사업장 공통 운영 정책" +) +public record CompanySettingsResponse( + @Schema( + description = "Task 승인·반려 명령을 수행할 수 있는 역할 범위", + requiredMode = Schema.RequiredMode.REQUIRED, + example = "ADMIN_OR_HR" + ) + ApprovalPolicy approvalPolicy, + @Schema( + description = "근로자 보안 링크 기본 만료시간. Server MVP 범위는 1~168시간", + minimum = "1", + maximum = "168", + requiredMode = Schema.RequiredMode.REQUIRED, + example = "72" + ) + long linkExpiryHours, + @Schema( + description = "TaskType별 회사 추가 필수 EvidenceType. 기존 필수 증빙 조건을 완화하지 않음", + requiredMode = Schema.RequiredMode.REQUIRED, + example = "{\"RECONTRACT\":[\"DOCUMENT\"]}" + ) + Map> evidenceRules, + @Schema( + description = "파일 보유기간(일). 정책 저장·조회만 제공", + minimum = "30", + maximum = "3650", + requiredMode = Schema.RequiredMode.REQUIRED, + example = "365" + ) + int fileRetentionDays, + @Schema( + description = "ai_attempt 품질·실행 상세 데이터 보유기간(일). ai_run aggregate는 제외", + minimum = "7", + maximum = "365", + requiredMode = Schema.RequiredMode.REQUIRED, + example = "90" + ) + int aiLogRetentionDays, + @Schema( + description = "company-wide 감사로그 검색을 허용할 역할 범위", + requiredMode = Schema.RequiredMode.REQUIRED, + example = "ADMIN_ONLY" + ) + AuditVisibility auditVisibility, + @Schema( + description = "회사 설정 aggregate version", + minimum = "0", + requiredMode = Schema.RequiredMode.REQUIRED, + example = "0" + ) + long version +) { + + static CompanySettingsResponse from(CompanySettings settings) { + return new CompanySettingsResponse( + settings.approvalPolicy(), + settings.linkExpiryHours(), + settings.evidenceRules(), + settings.fileRetentionDays(), + settings.aiLogRetentionDays(), + settings.auditVisibility(), + settings.version() + ); + } +} diff --git a/src/main/java/com/fowoco/server/settings/api/DetailedCompanyMemberResponse.java b/src/main/java/com/fowoco/server/settings/api/DetailedCompanyMemberResponse.java new file mode 100644 index 00000000..3a569bca --- /dev/null +++ b/src/main/java/com/fowoco/server/settings/api/DetailedCompanyMemberResponse.java @@ -0,0 +1,26 @@ +package com.fowoco.server.settings.api; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.fowoco.server.auth.domain.UserRole; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import java.util.UUID; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +@Schema( + name = "DetailedCompanyMemberResponse", + description = "ADMIN과 HR에 제공하는 구성원 projection" +) +public record DetailedCompanyMemberResponse( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID userId, + @Schema(maxLength = 80, requiredMode = Schema.RequiredMode.REQUIRED) String displayName, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) List roles, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean active, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean approvalPermission +) implements CompanyMemberItemResponse { + + public DetailedCompanyMemberResponse { + roles = List.copyOf(roles); + } +} diff --git a/src/main/java/com/fowoco/server/settings/api/MinimalCompanyMemberResponse.java b/src/main/java/com/fowoco/server/settings/api/MinimalCompanyMemberResponse.java new file mode 100644 index 00000000..98c8ba90 --- /dev/null +++ b/src/main/java/com/fowoco/server/settings/api/MinimalCompanyMemberResponse.java @@ -0,0 +1,17 @@ +package com.fowoco.server.settings.api; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.UUID; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +@Schema( + name = "MinimalCompanyMemberResponse", + description = "VIEWER에 제공하는 최소 구성원 projection" +) +public record MinimalCompanyMemberResponse( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID userId, + @Schema(maxLength = 80, requiredMode = Schema.RequiredMode.REQUIRED) String displayName +) implements CompanyMemberItemResponse { +} diff --git a/src/main/java/com/fowoco/server/settings/application/CompanyMemberService.java b/src/main/java/com/fowoco/server/settings/application/CompanyMemberService.java new file mode 100644 index 00000000..05d6253e --- /dev/null +++ b/src/main/java/com/fowoco/server/settings/application/CompanyMemberService.java @@ -0,0 +1,94 @@ +package com.fowoco.server.settings.application; + +import com.fowoco.server.auth.application.ActorAuthorizer; +import com.fowoco.server.auth.application.ActorContext; +import com.fowoco.server.auth.application.CompanyMemberAccount; +import com.fowoco.server.auth.application.port.CompanyMemberDirectory; +import com.fowoco.server.auth.domain.UserRole; +import com.fowoco.server.common.error.ApiException; +import com.fowoco.server.common.error.ErrorCode; +import com.fowoco.server.common.security.TenantDatabaseContext; +import com.fowoco.server.settings.application.port.CompanySettingsRepository; +import com.fowoco.server.settings.domain.ApprovalPolicy; +import java.util.List; +import java.util.Set; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class CompanyMemberService { + + private static final Set DETAILED_VIEW_ROLES = Set.of(UserRole.ADMIN, UserRole.HR); + + private final ActorAuthorizer actorAuthorizer; + private final TenantDatabaseContext tenantDatabaseContext; + private final CompanyMemberDirectory companyMemberDirectory; + private final CompanySettingsRepository companySettingsRepository; + + public CompanyMemberService( + ActorAuthorizer actorAuthorizer, + TenantDatabaseContext tenantDatabaseContext, + CompanyMemberDirectory companyMemberDirectory, + CompanySettingsRepository companySettingsRepository + ) { + this.actorAuthorizer = actorAuthorizer; + this.tenantDatabaseContext = tenantDatabaseContext; + this.companyMemberDirectory = companyMemberDirectory; + this.companySettingsRepository = companySettingsRepository; + } + + @Transactional(readOnly = true) + public List findAll( + ActorContext actor, + UserRole role, + Boolean approvalCapable, + boolean activeOnly + ) { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(actor.companyId()); + actorAuthorizer.requireAnyRole(actor, UserRole.ADMIN, UserRole.HR, UserRole.VIEWER); + boolean detailed = actor.hasAnyRole(DETAILED_VIEW_ROLES); + if (!detailed && (role != null || approvalCapable != null || !activeOnly)) { + throw new ApiException(ErrorCode.ACCESS_DENIED); + } + + List accounts = companyMemberDirectory.findByCompanyId( + actor.companyId(), + role, + activeOnly + ); + if (!detailed) { + return accounts.stream() + .map(account -> (CompanyMemberView) new MinimalCompanyMemberView( + account.userId(), + account.displayName() + )) + .toList(); + } + + ApprovalPolicy approvalPolicy = companySettingsRepository.findByCompanyId(actor.companyId()) + .orElseThrow(() -> new IllegalStateException( + "Persisted company settings are missing for company " + actor.companyId() + )) + .approvalPolicy(); + return accounts.stream() + .map(account -> detailedView(account, approvalPolicy)) + .filter(member -> approvalCapable == null + || member.approvalPermission() == approvalCapable) + .map(member -> (CompanyMemberView) member) + .toList(); + } + + private DetailedCompanyMemberView detailedView( + CompanyMemberAccount account, + ApprovalPolicy approvalPolicy + ) { + boolean active = account.active(); + return new DetailedCompanyMemberView( + account.userId(), + account.displayName(), + List.of(account.role()), + active, + active && approvalPolicy.permits(account.role()) + ); + } +} diff --git a/src/main/java/com/fowoco/server/settings/application/CompanyMemberView.java b/src/main/java/com/fowoco/server/settings/application/CompanyMemberView.java new file mode 100644 index 00000000..a21dffb4 --- /dev/null +++ b/src/main/java/com/fowoco/server/settings/application/CompanyMemberView.java @@ -0,0 +1,11 @@ +package com.fowoco.server.settings.application; + +import java.util.UUID; + +public sealed interface CompanyMemberView + permits DetailedCompanyMemberView, MinimalCompanyMemberView { + + UUID userId(); + + String displayName(); +} diff --git a/src/main/java/com/fowoco/server/settings/application/CompanySettingsAuditSummary.java b/src/main/java/com/fowoco/server/settings/application/CompanySettingsAuditSummary.java new file mode 100644 index 00000000..3267dcc1 --- /dev/null +++ b/src/main/java/com/fowoco/server/settings/application/CompanySettingsAuditSummary.java @@ -0,0 +1,74 @@ +package com.fowoco.server.settings.application; + +import com.fowoco.server.approval.domain.EvidenceType; +import com.fowoco.server.settings.domain.CompanySettings; +import com.fowoco.server.task.domain.TaskType; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; + +final class CompanySettingsAuditSummary { + + private static final int MAX_LENGTH = 500; + + private CompanySettingsAuditSummary() { + } + + static List changedFields(CompanySettings before, CompanySettings after) { + List changes = new ArrayList<>(); + add(changes, "approval_policy", before.approvalPolicy(), after.approvalPolicy(), before, after); + add(changes, "link_expiry_hours", before.linkExpiryHours(), after.linkExpiryHours(), before, after); + for (TaskType taskType : TaskType.values()) { + Set beforeTypes = before.evidenceRules().getOrDefault(taskType, Set.of()); + Set afterTypes = after.evidenceRules().getOrDefault(taskType, Set.of()); + if (!beforeTypes.equals(afterTypes)) { + changes.add(requireLength( + "evidence_rules." + taskType + ":" + + evidenceList(beforeTypes) + "->" + evidenceList(afterTypes) + + versionTransition(before, after) + )); + } + } + add(changes, "file_retention_days", before.fileRetentionDays(), after.fileRetentionDays(), before, after); + add(changes, "ai_log_retention_days", before.aiLogRetentionDays(), after.aiLogRetentionDays(), before, after); + add(changes, "audit_visibility", before.auditVisibility(), after.auditVisibility(), before, after); + return List.copyOf(changes); + } + + private static void add( + List changes, + String field, + Object beforeValue, + Object afterValue, + CompanySettings before, + CompanySettings after + ) { + if (!beforeValue.equals(afterValue)) { + changes.add(requireLength( + field + ":" + beforeValue + "->" + afterValue + + versionTransition(before, after) + )); + } + } + + private static String versionTransition(CompanySettings before, CompanySettings after) { + return ",version:" + before.version() + "->" + after.version(); + } + + private static String evidenceList(Set evidenceTypes) { + if (evidenceTypes.isEmpty()) { + return "[]"; + } + EnumSet sorted = EnumSet.copyOf(evidenceTypes); + return "[" + sorted.stream().map(Enum::name).reduce((left, right) -> left + "," + right) + .orElseThrow() + "]"; + } + + private static String requireLength(String summary) { + if (summary.length() > MAX_LENGTH) { + throw new IllegalStateException("Company settings audit summary exceeds 500 characters"); + } + return summary; + } +} diff --git a/src/main/java/com/fowoco/server/settings/application/CompanySettingsService.java b/src/main/java/com/fowoco/server/settings/application/CompanySettingsService.java new file mode 100644 index 00000000..60f490ab --- /dev/null +++ b/src/main/java/com/fowoco/server/settings/application/CompanySettingsService.java @@ -0,0 +1,120 @@ +package com.fowoco.server.settings.application; + +import com.fowoco.server.audit.application.port.AuditEventRepository; +import com.fowoco.server.audit.domain.ActorType; +import com.fowoco.server.audit.domain.AuditAction; +import com.fowoco.server.audit.domain.AuditEvent; +import com.fowoco.server.audit.domain.AuditTargetType; +import com.fowoco.server.auth.application.ActorAuthorizer; +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.error.ErrorCode; +import com.fowoco.server.common.id.UuidGenerator; +import com.fowoco.server.common.security.TenantDatabaseContext; +import com.fowoco.server.common.web.RequestMetadata; +import com.fowoco.server.settings.application.port.CompanySettingsRepository; +import com.fowoco.server.settings.domain.CompanySettings; +import java.time.Clock; +import java.time.Instant; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class CompanySettingsService { + + private static final String AUDIT_EVENT_VERSION = "1"; + + private final ActorAuthorizer actorAuthorizer; + private final TenantDatabaseContext tenantDatabaseContext; + private final CompanySettingsRepository companySettingsRepository; + private final AuditEventRepository auditEventRepository; + private final UuidGenerator uuidGenerator; + private final Clock clock; + + public CompanySettingsService( + ActorAuthorizer actorAuthorizer, + TenantDatabaseContext tenantDatabaseContext, + CompanySettingsRepository companySettingsRepository, + AuditEventRepository auditEventRepository, + UuidGenerator uuidGenerator, + Clock clock + ) { + this.actorAuthorizer = actorAuthorizer; + this.tenantDatabaseContext = tenantDatabaseContext; + this.companySettingsRepository = companySettingsRepository; + this.auditEventRepository = auditEventRepository; + this.uuidGenerator = uuidGenerator; + this.clock = clock; + } + + @Transactional(readOnly = true) + public CompanySettings get(ActorContext actor) { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(actor.companyId()); + actorAuthorizer.requireAnyRole(actor, UserRole.ADMIN, UserRole.HR, UserRole.VIEWER); + return companySettingsRepository.findByCompanyId(actor.companyId()) + .orElseThrow(() -> new IllegalStateException( + "Persisted company settings are missing for company " + actor.companyId() + )); + } + + @Transactional + public CompanySettings update( + ActorContext actor, + UpdateCompanySettingsCommand command, + RequestMetadata metadata + ) { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(actor.companyId()); + actorAuthorizer.requireAnyRole(actor, UserRole.ADMIN); + CompanySettings current = companySettingsRepository.findByCompanyId(actor.companyId()) + .orElseThrow(() -> new IllegalStateException( + "Persisted company settings are missing for company " + actor.companyId() + )); + if (current.version() != command.expectedVersion()) { + throw new ApiException(ErrorCode.CONCURRENT_MODIFICATION); + } + + Instant now = Instant.now(clock); + CompanySettings requested = current.update( + command.approvalPolicy().orElse(current.approvalPolicy()), + command.linkExpiryHours().orElse(current.linkExpiryHours()), + command.evidenceRules().orElse(current.evidenceRules()), + command.fileRetentionDays().orElse(current.fileRetentionDays()), + command.aiLogRetentionDays().orElse(current.aiLogRetentionDays()), + command.auditVisibility().orElse(current.auditVisibility()), + now + ); + if (samePolicies(current, requested)) { + return current; + } + + CompanySettings saved = companySettingsRepository.update(requested); + CompanySettingsAuditSummary.changedFields(current, saved).forEach(summary -> + auditEventRepository.append(new AuditEvent( + uuidGenerator.generate(), + actor.companyId(), + ActorType.HR_USER, + actor.actorId(), + UserRole.ADMIN, + AuditAction.SETTINGS_UPDATED, + AuditTargetType.COMPANY_SETTINGS, + actor.companyId(), + metadata.requestId(), + metadata.traceId(), + AUDIT_EVENT_VERSION, + summary, + now + )) + ); + return saved; + } + + private boolean samePolicies(CompanySettings left, CompanySettings right) { + return left.approvalPolicy() == right.approvalPolicy() + && left.linkExpiryHours() == right.linkExpiryHours() + && left.evidenceRules().equals(right.evidenceRules()) + && left.fileRetentionDays() == right.fileRetentionDays() + && left.aiLogRetentionDays() == right.aiLogRetentionDays() + && left.auditVisibility() == right.auditVisibility(); + } +} diff --git a/src/main/java/com/fowoco/server/settings/application/DefaultCompanySettingsProvisioner.java b/src/main/java/com/fowoco/server/settings/application/DefaultCompanySettingsProvisioner.java new file mode 100644 index 00000000..46611d0d --- /dev/null +++ b/src/main/java/com/fowoco/server/settings/application/DefaultCompanySettingsProvisioner.java @@ -0,0 +1,26 @@ +package com.fowoco.server.settings.application; + +import com.fowoco.server.company.application.port.CompanySettingsProvisioner; +import com.fowoco.server.settings.application.port.CompanySettingsRepository; +import com.fowoco.server.settings.domain.CompanySettings; +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; +import org.springframework.stereotype.Service; + +@Service +public class DefaultCompanySettingsProvisioner implements CompanySettingsProvisioner { + + private final CompanySettingsRepository companySettingsRepository; + + public DefaultCompanySettingsProvisioner(CompanySettingsRepository companySettingsRepository) { + this.companySettingsRepository = companySettingsRepository; + } + + @Override + public void provisionDefaults(UUID companyId, Instant now) { + Objects.requireNonNull(companyId, "companyId must not be null"); + Objects.requireNonNull(now, "now must not be null"); + companySettingsRepository.insert(CompanySettings.defaults(companyId, now)); + } +} diff --git a/src/main/java/com/fowoco/server/settings/application/DetailedCompanyMemberView.java b/src/main/java/com/fowoco/server/settings/application/DetailedCompanyMemberView.java new file mode 100644 index 00000000..b9f71349 --- /dev/null +++ b/src/main/java/com/fowoco/server/settings/application/DetailedCompanyMemberView.java @@ -0,0 +1,18 @@ +package com.fowoco.server.settings.application; + +import com.fowoco.server.auth.domain.UserRole; +import java.util.List; +import java.util.UUID; + +public record DetailedCompanyMemberView( + UUID userId, + String displayName, + List roles, + boolean active, + boolean approvalPermission +) implements CompanyMemberView { + + public DetailedCompanyMemberView { + roles = List.copyOf(roles); + } +} diff --git a/src/main/java/com/fowoco/server/settings/application/MinimalCompanyMemberView.java b/src/main/java/com/fowoco/server/settings/application/MinimalCompanyMemberView.java new file mode 100644 index 00000000..ca2c5c38 --- /dev/null +++ b/src/main/java/com/fowoco/server/settings/application/MinimalCompanyMemberView.java @@ -0,0 +1,9 @@ +package com.fowoco.server.settings.application; + +import java.util.UUID; + +public record MinimalCompanyMemberView( + UUID userId, + String displayName +) implements CompanyMemberView { +} diff --git a/src/main/java/com/fowoco/server/settings/application/PatchField.java b/src/main/java/com/fowoco/server/settings/application/PatchField.java new file mode 100644 index 00000000..7cbcfbaa --- /dev/null +++ b/src/main/java/com/fowoco/server/settings/application/PatchField.java @@ -0,0 +1,26 @@ +package com.fowoco.server.settings.application; + +import java.util.Objects; + +public record PatchField(boolean present, T value) { + + public PatchField { + if (present) { + Objects.requireNonNull(value, "present patch value must not be null"); + } else if (value != null) { + throw new IllegalArgumentException("absent patch value must be null"); + } + } + + public static PatchField absent() { + return new PatchField<>(false, null); + } + + public static PatchField of(T value) { + return new PatchField<>(true, value); + } + + public T orElse(T currentValue) { + return present ? value : currentValue; + } +} diff --git a/src/main/java/com/fowoco/server/settings/application/UpdateCompanySettingsCommand.java b/src/main/java/com/fowoco/server/settings/application/UpdateCompanySettingsCommand.java new file mode 100644 index 00000000..2eb5c9bb --- /dev/null +++ b/src/main/java/com/fowoco/server/settings/application/UpdateCompanySettingsCommand.java @@ -0,0 +1,19 @@ +package com.fowoco.server.settings.application; + +import com.fowoco.server.approval.domain.EvidenceType; +import com.fowoco.server.settings.domain.ApprovalPolicy; +import com.fowoco.server.settings.domain.AuditVisibility; +import com.fowoco.server.task.domain.TaskType; +import java.util.Map; +import java.util.Set; + +public record UpdateCompanySettingsCommand( + long expectedVersion, + PatchField approvalPolicy, + PatchField linkExpiryHours, + PatchField>> evidenceRules, + PatchField fileRetentionDays, + PatchField aiLogRetentionDays, + PatchField auditVisibility +) { +} diff --git a/src/main/java/com/fowoco/server/settings/application/port/CompanySettingsRepository.java b/src/main/java/com/fowoco/server/settings/application/port/CompanySettingsRepository.java new file mode 100644 index 00000000..9664fd7a --- /dev/null +++ b/src/main/java/com/fowoco/server/settings/application/port/CompanySettingsRepository.java @@ -0,0 +1,14 @@ +package com.fowoco.server.settings.application.port; + +import com.fowoco.server.settings.domain.CompanySettings; +import java.util.Optional; +import java.util.UUID; + +public interface CompanySettingsRepository { + + Optional findByCompanyId(UUID companyId); + + void insert(CompanySettings companySettings); + + CompanySettings update(CompanySettings companySettings); +} diff --git a/src/main/java/com/fowoco/server/settings/domain/ApprovalPolicy.java b/src/main/java/com/fowoco/server/settings/domain/ApprovalPolicy.java new file mode 100644 index 00000000..ed45c360 --- /dev/null +++ b/src/main/java/com/fowoco/server/settings/domain/ApprovalPolicy.java @@ -0,0 +1,17 @@ +package com.fowoco.server.settings.domain; + +import com.fowoco.server.auth.domain.UserRole; +import java.util.Objects; + +public enum ApprovalPolicy { + ADMIN_ONLY, + ADMIN_OR_HR; + + public boolean permits(UserRole role) { + Objects.requireNonNull(role, "role must not be null"); + return switch (this) { + case ADMIN_ONLY -> role == UserRole.ADMIN; + case ADMIN_OR_HR -> role == UserRole.ADMIN || role == UserRole.HR; + }; + } +} diff --git a/src/main/java/com/fowoco/server/settings/domain/AuditVisibility.java b/src/main/java/com/fowoco/server/settings/domain/AuditVisibility.java new file mode 100644 index 00000000..7559c2e9 --- /dev/null +++ b/src/main/java/com/fowoco/server/settings/domain/AuditVisibility.java @@ -0,0 +1,17 @@ +package com.fowoco.server.settings.domain; + +import com.fowoco.server.auth.domain.UserRole; +import java.util.Objects; + +public enum AuditVisibility { + ADMIN_ONLY, + ADMIN_AND_HR; + + public boolean permits(UserRole role) { + Objects.requireNonNull(role, "role must not be null"); + return switch (this) { + case ADMIN_ONLY -> role == UserRole.ADMIN; + case ADMIN_AND_HR -> role == UserRole.ADMIN || role == UserRole.HR; + }; + } +} diff --git a/src/main/java/com/fowoco/server/settings/domain/CompanySettings.java b/src/main/java/com/fowoco/server/settings/domain/CompanySettings.java new file mode 100644 index 00000000..b49da4f2 --- /dev/null +++ b/src/main/java/com/fowoco/server/settings/domain/CompanySettings.java @@ -0,0 +1,193 @@ +package com.fowoco.server.settings.domain; + +import com.fowoco.server.approval.domain.EvidenceType; +import com.fowoco.server.task.domain.TaskType; +import java.time.Instant; +import java.util.Collections; +import java.util.EnumMap; +import java.util.EnumSet; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; + +public final class CompanySettings { + + public static final ApprovalPolicy DEFAULT_APPROVAL_POLICY = ApprovalPolicy.ADMIN_OR_HR; + public static final long DEFAULT_LINK_EXPIRY_HOURS = 72L; + public static final int DEFAULT_FILE_RETENTION_DAYS = 365; + public static final int DEFAULT_AI_LOG_RETENTION_DAYS = 90; + public static final AuditVisibility DEFAULT_AUDIT_VISIBILITY = AuditVisibility.ADMIN_ONLY; + + public static final long MIN_LINK_EXPIRY_HOURS = 1L; + public static final long MAX_LINK_EXPIRY_HOURS = 168L; + public static final int MIN_FILE_RETENTION_DAYS = 30; + public static final int MAX_FILE_RETENTION_DAYS = 3650; + public static final int MIN_AI_LOG_RETENTION_DAYS = 7; + public static final int MAX_AI_LOG_RETENTION_DAYS = 365; + + private final UUID companyId; + private final ApprovalPolicy approvalPolicy; + private final long linkExpiryHours; + private final Map> evidenceRules; + private final int fileRetentionDays; + private final int aiLogRetentionDays; + private final AuditVisibility auditVisibility; + private final Instant createdAt; + private final Instant updatedAt; + private final long version; + + public CompanySettings( + UUID companyId, + ApprovalPolicy approvalPolicy, + long linkExpiryHours, + Map> evidenceRules, + int fileRetentionDays, + int aiLogRetentionDays, + AuditVisibility auditVisibility, + Instant createdAt, + Instant updatedAt, + long version + ) { + this.companyId = Objects.requireNonNull(companyId, "companyId must not be null"); + this.approvalPolicy = Objects.requireNonNull(approvalPolicy, "approvalPolicy must not be null"); + this.linkExpiryHours = requireRange( + linkExpiryHours, + MIN_LINK_EXPIRY_HOURS, + MAX_LINK_EXPIRY_HOURS, + "linkExpiryHours" + ); + this.evidenceRules = immutableEvidenceRules(evidenceRules); + this.fileRetentionDays = (int) requireRange( + fileRetentionDays, + MIN_FILE_RETENTION_DAYS, + MAX_FILE_RETENTION_DAYS, + "fileRetentionDays" + ); + this.aiLogRetentionDays = (int) requireRange( + aiLogRetentionDays, + MIN_AI_LOG_RETENTION_DAYS, + MAX_AI_LOG_RETENTION_DAYS, + "aiLogRetentionDays" + ); + this.auditVisibility = Objects.requireNonNull(auditVisibility, "auditVisibility must not be null"); + this.createdAt = Objects.requireNonNull(createdAt, "createdAt must not be null"); + this.updatedAt = Objects.requireNonNull(updatedAt, "updatedAt must not be null"); + if (updatedAt.isBefore(createdAt)) { + throw new IllegalArgumentException("updatedAt must not be before createdAt"); + } + if (version < 0) { + throw new IllegalArgumentException("version must not be negative"); + } + this.version = version; + } + + public static CompanySettings defaults(UUID companyId, Instant now) { + Objects.requireNonNull(now, "now must not be null"); + return new CompanySettings( + companyId, + DEFAULT_APPROVAL_POLICY, + DEFAULT_LINK_EXPIRY_HOURS, + Map.of(), + DEFAULT_FILE_RETENTION_DAYS, + DEFAULT_AI_LOG_RETENTION_DAYS, + DEFAULT_AUDIT_VISIBILITY, + now, + now, + 0L + ); + } + + public UUID companyId() { + return companyId; + } + + public ApprovalPolicy approvalPolicy() { + return approvalPolicy; + } + + public long linkExpiryHours() { + return linkExpiryHours; + } + + public Map> evidenceRules() { + return evidenceRules; + } + + public int fileRetentionDays() { + return fileRetentionDays; + } + + public int aiLogRetentionDays() { + return aiLogRetentionDays; + } + + public AuditVisibility auditVisibility() { + return auditVisibility; + } + + public Instant createdAt() { + return createdAt; + } + + public Instant updatedAt() { + return updatedAt; + } + + public long version() { + return version; + } + + public CompanySettings update( + ApprovalPolicy approvalPolicy, + long linkExpiryHours, + Map> evidenceRules, + int fileRetentionDays, + int aiLogRetentionDays, + AuditVisibility auditVisibility, + Instant now + ) { + Objects.requireNonNull(now, "now must not be null"); + Instant effectiveUpdatedAt = now.isBefore(updatedAt) ? updatedAt : now; + return new CompanySettings( + companyId, + approvalPolicy, + linkExpiryHours, + evidenceRules, + fileRetentionDays, + aiLogRetentionDays, + auditVisibility, + createdAt, + effectiveUpdatedAt, + version + ); + } + + private static long requireRange(long value, long min, long max, String fieldName) { + if (value < min || value > max) { + throw new IllegalArgumentException( + fieldName + " must be between " + min + " and " + max + ); + } + return value; + } + + private static Map> immutableEvidenceRules( + Map> evidenceRules + ) { + Objects.requireNonNull(evidenceRules, "evidenceRules must not be null"); + EnumMap> copy = new EnumMap<>(TaskType.class); + evidenceRules.forEach((taskType, evidenceTypes) -> { + Objects.requireNonNull(taskType, "evidenceRules taskType must not be null"); + Objects.requireNonNull(evidenceTypes, "evidenceRules evidenceTypes must not be null"); + EnumSet evidenceTypeCopy = evidenceTypes.isEmpty() + ? EnumSet.noneOf(EvidenceType.class) + : EnumSet.copyOf(evidenceTypes); + if (evidenceTypeCopy.size() != evidenceTypes.size()) { + throw new IllegalArgumentException("evidenceRules must not contain null evidence types"); + } + copy.put(taskType, Collections.unmodifiableSet(evidenceTypeCopy)); + }); + return Collections.unmodifiableMap(copy); + } +} diff --git a/src/main/java/com/fowoco/server/settings/infrastructure/persistence/CompanySettingsJpaEntity.java b/src/main/java/com/fowoco/server/settings/infrastructure/persistence/CompanySettingsJpaEntity.java new file mode 100644 index 00000000..1e59ee85 --- /dev/null +++ b/src/main/java/com/fowoco/server/settings/infrastructure/persistence/CompanySettingsJpaEntity.java @@ -0,0 +1,116 @@ +package com.fowoco.server.settings.infrastructure.persistence; + +import com.fowoco.server.settings.domain.ApprovalPolicy; +import com.fowoco.server.settings.domain.AuditVisibility; +import com.fowoco.server.settings.domain.CompanySettings; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +@Entity +@Table(name = "company_settings") +class CompanySettingsJpaEntity { + + @Id + @Column(name = "company_id", nullable = false, updatable = false) + private UUID companyId; + + @Enumerated(EnumType.STRING) + @Column(name = "approval_policy", nullable = false, length = 30) + private ApprovalPolicy approvalPolicy; + + @Column(name = "link_expiry_hours", nullable = false) + private long linkExpiryHours; + + @Column(name = "evidence_rules_json", nullable = false, columnDefinition = "TEXT") + private String evidenceRulesJson; + + @Column(name = "file_retention_days", nullable = false) + private int fileRetentionDays; + + @Column(name = "ai_log_retention_days", nullable = false) + private int aiLogRetentionDays; + + @Enumerated(EnumType.STRING) + @Column(name = "audit_visibility", nullable = false, length = 30) + private AuditVisibility auditVisibility; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @Version + @Column(name = "version", nullable = false) + private long version; + + protected CompanySettingsJpaEntity() { + } + + private CompanySettingsJpaEntity( + UUID companyId, + ApprovalPolicy approvalPolicy, + long linkExpiryHours, + String evidenceRulesJson, + int fileRetentionDays, + int aiLogRetentionDays, + AuditVisibility auditVisibility, + Instant createdAt, + Instant updatedAt, + long version + ) { + this.companyId = companyId; + this.approvalPolicy = approvalPolicy; + this.linkExpiryHours = linkExpiryHours; + this.evidenceRulesJson = evidenceRulesJson; + this.fileRetentionDays = fileRetentionDays; + this.aiLogRetentionDays = aiLogRetentionDays; + this.auditVisibility = auditVisibility; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + this.version = version; + } + + static CompanySettingsJpaEntity fromDomain( + CompanySettings companySettings, + CompanySettingsJsonCodec jsonCodec + ) { + Objects.requireNonNull(companySettings, "companySettings must not be null"); + Objects.requireNonNull(jsonCodec, "jsonCodec must not be null"); + return new CompanySettingsJpaEntity( + companySettings.companyId(), + companySettings.approvalPolicy(), + companySettings.linkExpiryHours(), + jsonCodec.encodeEvidenceRules(companySettings.evidenceRules()), + companySettings.fileRetentionDays(), + companySettings.aiLogRetentionDays(), + companySettings.auditVisibility(), + companySettings.createdAt(), + companySettings.updatedAt(), + companySettings.version() + ); + } + + CompanySettings toDomain(CompanySettingsJsonCodec jsonCodec) { + return new CompanySettings( + companyId, + approvalPolicy, + linkExpiryHours, + jsonCodec.decodeEvidenceRules(evidenceRulesJson), + fileRetentionDays, + aiLogRetentionDays, + auditVisibility, + createdAt, + updatedAt, + version + ); + } +} diff --git a/src/main/java/com/fowoco/server/settings/infrastructure/persistence/CompanySettingsJsonCodec.java b/src/main/java/com/fowoco/server/settings/infrastructure/persistence/CompanySettingsJsonCodec.java new file mode 100644 index 00000000..5cf3cfd4 --- /dev/null +++ b/src/main/java/com/fowoco/server/settings/infrastructure/persistence/CompanySettingsJsonCodec.java @@ -0,0 +1,76 @@ +package com.fowoco.server.settings.infrastructure.persistence; + +import com.fowoco.server.approval.domain.EvidenceType; +import com.fowoco.server.task.domain.TaskType; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.EnumSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.springframework.stereotype.Component; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; + +@Component +class CompanySettingsJsonCodec { + + private final ObjectMapper objectMapper; + + CompanySettingsJsonCodec(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + String encodeEvidenceRules(Map> evidenceRules) { + Map> encoded = new LinkedHashMap<>(); + evidenceRules.entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .forEach(entry -> encoded.put( + entry.getKey().name(), + entry.getValue().stream().sorted().map(Enum::name).toList() + )); + try { + return objectMapper.writeValueAsString(encoded); + } catch (JacksonException exception) { + throw new IllegalArgumentException("Company evidence rules cannot be encoded.", exception); + } + } + + @SuppressWarnings("unchecked") + Map> decodeEvidenceRules(String evidenceRulesJson) { + try { + Map decoded = objectMapper.readValue(evidenceRulesJson, Map.class); + if (decoded == null) { + throw new IllegalArgumentException("Evidence rules must be a JSON object."); + } + EnumMap> evidenceRules = new EnumMap<>(TaskType.class); + decoded.forEach((rawTaskType, rawEvidenceTypes) -> evidenceRules.put( + TaskType.valueOf(rawTaskType), + decodeEvidenceTypes(rawEvidenceTypes) + )); + return evidenceRules; + } catch (JacksonException | IllegalArgumentException exception) { + throw new IllegalStateException("Stored company evidence rules are invalid.", exception); + } + } + + private Set decodeEvidenceTypes(Object rawEvidenceTypes) { + if (!(rawEvidenceTypes instanceof List values)) { + throw new IllegalArgumentException("Evidence rule value must be an array."); + } + List evidenceTypes = new ArrayList<>(); + for (Object value : values) { + if (!(value instanceof String evidenceType)) { + throw new IllegalArgumentException("Evidence type must be a string."); + } + evidenceTypes.add(EvidenceType.valueOf(evidenceType)); + } + if (evidenceTypes.size() != evidenceTypes.stream().distinct().count()) { + throw new IllegalArgumentException("Evidence rule must not contain duplicates."); + } + return evidenceTypes.isEmpty() + ? EnumSet.noneOf(EvidenceType.class) + : EnumSet.copyOf(evidenceTypes); + } +} diff --git a/src/main/java/com/fowoco/server/settings/infrastructure/persistence/JpaCompanySettingsRepository.java b/src/main/java/com/fowoco/server/settings/infrastructure/persistence/JpaCompanySettingsRepository.java new file mode 100644 index 00000000..7fafd4a4 --- /dev/null +++ b/src/main/java/com/fowoco/server/settings/infrastructure/persistence/JpaCompanySettingsRepository.java @@ -0,0 +1,48 @@ +package com.fowoco.server.settings.infrastructure.persistence; + +import com.fowoco.server.settings.application.port.CompanySettingsRepository; +import com.fowoco.server.settings.domain.CompanySettings; +import jakarta.persistence.EntityManager; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import org.springframework.stereotype.Repository; + +@Repository +public class JpaCompanySettingsRepository implements CompanySettingsRepository { + + private final EntityManager entityManager; + private final CompanySettingsJsonCodec jsonCodec; + + public JpaCompanySettingsRepository( + EntityManager entityManager, + CompanySettingsJsonCodec jsonCodec + ) { + this.entityManager = entityManager; + this.jsonCodec = jsonCodec; + } + + @Override + public Optional findByCompanyId(UUID companyId) { + Objects.requireNonNull(companyId, "companyId must not be null"); + return Optional.ofNullable(entityManager.find(CompanySettingsJpaEntity.class, companyId)) + .map(entity -> entity.toDomain(jsonCodec)); + } + + @Override + public void insert(CompanySettings companySettings) { + Objects.requireNonNull(companySettings, "companySettings must not be null"); + entityManager.persist(CompanySettingsJpaEntity.fromDomain(companySettings, jsonCodec)); + entityManager.flush(); + } + + @Override + public CompanySettings update(CompanySettings companySettings) { + Objects.requireNonNull(companySettings, "companySettings must not be null"); + CompanySettingsJpaEntity merged = entityManager.merge( + CompanySettingsJpaEntity.fromDomain(companySettings, jsonCodec) + ); + entityManager.flush(); + return merged.toDomain(jsonCodec); + } +} diff --git a/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkIssueRequest.java b/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkIssueRequest.java index 49e4dd42..0f17099e 100644 --- a/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkIssueRequest.java +++ b/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkIssueRequest.java @@ -2,12 +2,23 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fowoco.server.settings.domain.CompanySettings; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; @Schema(name = "WorkerLinkIssueRequest", description = "근로자 보안 링크 발급 요청") public final class WorkerLinkIssueRequest { - @Schema(name = "expires_in_hours", description = "링크 유효 시간(시간 단위)", example = "72") + @Min(CompanySettings.MIN_LINK_EXPIRY_HOURS) + @Max(CompanySettings.MAX_LINK_EXPIRY_HOURS) + @Schema( + name = "expires_in_hours", + description = "링크 유효 시간(시간 단위). 생략하면 회사 설정값을 사용합니다.", + minimum = "1", + maximum = "168", + example = "72" + ) private final Long expiresInHours; @Schema(name = "rotate_existing", description = "기존 활성 링크를 폐기하고 재발급할지 여부") 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 b7f298b7..18a15dcc 100644 --- a/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkService.java +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkService.java @@ -6,6 +6,9 @@ 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.common.time.DatabaseTimestamp; +import com.fowoco.server.settings.application.port.CompanySettingsRepository; +import com.fowoco.server.settings.domain.CompanySettings; import com.fowoco.server.task.application.port.TaskRepository; import com.fowoco.server.task.domain.Task; import com.fowoco.server.workerlink.application.error.WorkerLinkErrorCode; @@ -23,10 +26,9 @@ @Service public class WorkerLinkService { - private static final long DEFAULT_EXPIRES_IN_HOURS = 72L; - private final TaskRepository taskRepository; private final ApprovalRequestRepository approvalRequestRepository; + private final CompanySettingsRepository companySettingsRepository; private final WorkerLinkRepository workerLinkRepository; private final WorkerLinkGenerator workerLinkGenerator; private final WorkerLinkHasher workerLinkHasher; @@ -37,6 +39,7 @@ public class WorkerLinkService { public WorkerLinkService( TaskRepository taskRepository, ApprovalRequestRepository approvalRequestRepository, + CompanySettingsRepository companySettingsRepository, WorkerLinkRepository workerLinkRepository, WorkerLinkGenerator workerLinkGenerator, WorkerLinkHasher workerLinkHasher, @@ -46,6 +49,7 @@ public WorkerLinkService( ) { this.taskRepository = taskRepository; this.approvalRequestRepository = approvalRequestRepository; + this.companySettingsRepository = companySettingsRepository; this.workerLinkRepository = workerLinkRepository; this.workerLinkGenerator = workerLinkGenerator; this.workerLinkHasher = workerLinkHasher; @@ -87,7 +91,7 @@ public WorkerLinkIssueResult issue(WorkerLinkIssueCommand command, ActorContext ); } - Instant now = clock.instant(); + Instant now = DatabaseTimestamp.now(clock); Optional existingActive = workerLinkRepository .findActiveByTaskIdAndCompanyId(command.taskId(), actor.companyId()); @@ -100,7 +104,7 @@ public WorkerLinkIssueResult issue(WorkerLinkIssueCommand command, ActorContext workerLinkRepository.update(previousLink.revoke(now)); } - long hours = command.expiresInHours() != null ? command.expiresInHours() : DEFAULT_EXPIRES_IN_HOURS; + long hours = resolveExpiryHours(command, actor); Instant expiresAt = now.plus(Duration.ofHours(hours)); WorkerLinkGenerator.GeneratedWorkerLinkToken generated = workerLinkGenerator.generate(); @@ -127,4 +131,20 @@ public WorkerLinkIssueResult issue(WorkerLinkIssueCommand command, ActorContext false ); } + + private long resolveExpiryHours(WorkerLinkIssueCommand command, ActorContext actor) { + long hours = command.expiresInHours() != null + ? command.expiresInHours() + : companySettingsRepository.findByCompanyId(actor.companyId()) + .orElseThrow(() -> new IllegalStateException( + "Persisted company settings are missing for company " + + actor.companyId() + )) + .linkExpiryHours(); + if (hours < CompanySettings.MIN_LINK_EXPIRY_HOURS + || hours > CompanySettings.MAX_LINK_EXPIRY_HOURS) { + throw new IllegalArgumentException("expiresInHours must be between 1 and 168"); + } + return hours; + } } diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseManagementService.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseManagementService.java index 48684350..24781e97 100644 --- a/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseManagementService.java +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseManagementService.java @@ -10,6 +10,7 @@ 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.common.time.DatabaseTimestamp; import com.fowoco.server.common.web.RequestMetadata; import com.fowoco.server.task.application.error.TaskErrorCode; import com.fowoco.server.task.application.port.TaskRepository; @@ -86,13 +87,15 @@ public WorkerResponsePageResult findAll(UUID taskId, int page, int size, ActorCo public void markReviewed(UUID taskId, ActorContext actor, RequestMetadata metadata) { tenantDatabaseContext.setCompanyIdForCurrentTransaction(actor.companyId()); requireTask(taskId, actor.companyId()); - Instant now = clock.instant(); + Instant now = DatabaseTimestamp.now(clock); List unreadLinks = workerLinkRepository .findAllByTaskIdAndCompanyId(taskId, actor.companyId()) .stream() .filter(link -> link.conversationStatus() == ConversationStatus.NEEDS_FOLLOWUP) .toList(); - unreadLinks.forEach(link -> workerLinkRepository.update(link.markReviewed(now))); + unreadLinks.forEach(link -> workerLinkRepository.update(link.markReviewed( + DatabaseTimestamp.nowNotBefore(clock, link.createdAt()) + ))); if (unreadLinks.isEmpty()) { return; } 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 d73740b6..c26f251c 100644 --- a/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseService.java +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseService.java @@ -8,6 +8,7 @@ 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.common.time.DatabaseTimestamp; import com.fowoco.server.common.web.RequestMetadata; import com.fowoco.server.file.application.port.StoredFileRepository; import com.fowoco.server.file.domain.StoredFile; @@ -78,7 +79,7 @@ public WorkerResponseSubmitResult submit(WorkerResponseSubmitCommand command, Re WorkerLink link = workerLinkRepository.findByTokenHash(tokenHash) .orElseThrow(() -> new ApiException(WorkerLinkErrorCode.WORKER_LINK_NOT_FOUND)); - Instant now = clock.instant(); + Instant now = DatabaseTimestamp.nowNotBefore(clock, link.createdAt()); if (!link.isUsable(now)) { throw new ApiException(WorkerLinkErrorCode.WORKER_LINK_NOT_FOUND); } diff --git a/src/main/resources/db/migration-postgresql/V36__prepare_company_settings_rls.sql b/src/main/resources/db/migration-postgresql/V36__prepare_company_settings_rls.sql new file mode 100644 index 00000000..f9a7c4e6 --- /dev/null +++ b/src/main/resources/db/migration-postgresql/V36__prepare_company_settings_rls.sql @@ -0,0 +1,10 @@ +CREATE POLICY pl_company_settings_tenant_isolation + ON public.company_settings + 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/V35__create_company_settings.sql b/src/main/resources/db/migration/V35__create_company_settings.sql new file mode 100644 index 00000000..7e4433ba --- /dev/null +++ b/src/main/resources/db/migration/V35__create_company_settings.sql @@ -0,0 +1,33 @@ +CREATE TABLE company_settings ( + company_id UUID NOT NULL, + approval_policy VARCHAR(30) NOT NULL DEFAULT 'ADMIN_OR_HR', + link_expiry_hours BIGINT NOT NULL DEFAULT 72, + evidence_rules_json TEXT NOT NULL DEFAULT '{}', + file_retention_days INTEGER NOT NULL DEFAULT 365, + ai_log_retention_days INTEGER NOT NULL DEFAULT 90, + audit_visibility VARCHAR(30) NOT NULL DEFAULT 'ADMIN_ONLY', + created_at TIMESTAMP(6) WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP(6) WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + version BIGINT NOT NULL DEFAULT 0, + CONSTRAINT pk_company_settings PRIMARY KEY (company_id), + CONSTRAINT fk_company_settings_company + FOREIGN KEY (company_id) REFERENCES company (company_id) ON DELETE CASCADE, + CONSTRAINT ck_company_settings_approval_policy + CHECK (approval_policy IN ('ADMIN_ONLY', 'ADMIN_OR_HR')), + CONSTRAINT ck_company_settings_link_expiry_hours + CHECK (link_expiry_hours BETWEEN 1 AND 168), + CONSTRAINT ck_company_settings_evidence_rules_json_not_blank + CHECK (CHAR_LENGTH(TRIM(evidence_rules_json)) > 0), + CONSTRAINT ck_company_settings_file_retention_days + CHECK (file_retention_days BETWEEN 30 AND 3650), + CONSTRAINT ck_company_settings_ai_log_retention_days + CHECK (ai_log_retention_days BETWEEN 7 AND 365), + CONSTRAINT ck_company_settings_audit_visibility + CHECK (audit_visibility IN ('ADMIN_ONLY', 'ADMIN_AND_HR')), + CONSTRAINT ck_company_settings_version CHECK (version >= 0), + CONSTRAINT ck_company_settings_time_order CHECK (updated_at >= created_at) +); + +INSERT INTO company_settings (company_id) +SELECT company_id +FROM company; diff --git a/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java b/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java index fb6ad45b..64967da6 100644 --- a/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java +++ b/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java @@ -74,6 +74,7 @@ private void assertSchemaContract(Connection connection) throws SQLException { assertThat(tableNames(connection)) .contains( "company", + "company_settings", "user_account", "refresh_token", "worker", @@ -116,6 +117,15 @@ private void assertSchemaContract(Connection connection) throws SQLException { .containsEntry("status", new ColumnSpec("varchar", false)) .containsEntry("version", new ColumnSpec("int8", false)) .doesNotContainKey("company_name"); + assertThat(columnSpecs(connection, "company_settings")) + .containsEntry("company_id", new ColumnSpec("uuid", false)) + .containsEntry("approval_policy", new ColumnSpec("varchar", false)) + .containsEntry("link_expiry_hours", new ColumnSpec("int8", false)) + .containsEntry("evidence_rules_json", new ColumnSpec("text", false)) + .containsEntry("file_retention_days", new ColumnSpec("int4", false)) + .containsEntry("ai_log_retention_days", new ColumnSpec("int4", false)) + .containsEntry("audit_visibility", new ColumnSpec("varchar", false)) + .containsEntry("version", new ColumnSpec("int8", false)); assertThat(columnSpecs(connection, "user_account")) .containsEntry("user_id", new ColumnSpec("uuid", false)) .containsEntry("company_id", new ColumnSpec("uuid", false)) @@ -338,6 +348,16 @@ private void assertSchemaContract(Connection connection) throws SQLException { assertThat(constraintNames(connection)) .contains( "pk_company", + "pk_company_settings", + "fk_company_settings_company", + "ck_company_settings_approval_policy", + "ck_company_settings_link_expiry_hours", + "ck_company_settings_evidence_rules_json_not_blank", + "ck_company_settings_file_retention_days", + "ck_company_settings_ai_log_retention_days", + "ck_company_settings_audit_visibility", + "ck_company_settings_version", + "ck_company_settings_time_order", "pk_user_account", "fk_user_account_company", "uq_user_account_normalized_email", @@ -470,6 +490,7 @@ private void assertSchemaContract(Connection connection) throws SQLException { assertThat(policyNames(connection)) .containsExactlyInAnyOrder( "pl_company_tenant_isolation", + "pl_company_settings_tenant_isolation", "pl_user_account_tenant_isolation", "pl_refresh_token_tenant_isolation", "pl_worker_tenant_isolation", diff --git a/src/test/java/com/fowoco/server/approval/ApprovalAuditIntegrationTest.java b/src/test/java/com/fowoco/server/approval/ApprovalAuditIntegrationTest.java index 3eb7845e..bcfe395b 100644 --- a/src/test/java/com/fowoco/server/approval/ApprovalAuditIntegrationTest.java +++ b/src/test/java/com/fowoco/server/approval/ApprovalAuditIntegrationTest.java @@ -15,6 +15,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -67,6 +68,25 @@ class ApprovalAuditIntegrationTest { @BeforeEach void resetAndSeed() { + cleanDatabase(); + + insertCompany(COMPANY_A, "승인 테스트 사업장 A"); + insertCompany(COMPANY_B, "승인 테스트 사업장 B"); + String passwordHash = passwordEncoder.encode(PASSWORD); + insertUser(ADMIN_A, COMPANY_A, ADMIN_A_EMAIL, passwordHash, "ADMIN"); + insertUser(HR_A, COMPANY_A, HR_A_EMAIL, passwordHash, "HR"); + insertUser(VIEWER_A, COMPANY_A, VIEWER_A_EMAIL, passwordHash, "VIEWER"); + insertUser(ADMIN_B, COMPANY_B, ADMIN_B_EMAIL, passwordHash, "ADMIN"); + insertWorker(); + insertDraftTask(); + } + + @AfterEach + void cleanAfterTest() { + cleanDatabase(); + } + + private void cleanDatabase() { jdbcTemplate.update("DELETE FROM event_consumption"); jdbcTemplate.update("DELETE FROM event_publication"); jdbcTemplate.update("DELETE FROM audit_event"); @@ -80,16 +100,6 @@ void resetAndSeed() { jdbcTemplate.update("DELETE FROM refresh_token"); jdbcTemplate.update("DELETE FROM user_account"); jdbcTemplate.update("DELETE FROM company"); - - insertCompany(COMPANY_A, "승인 테스트 사업장 A"); - insertCompany(COMPANY_B, "승인 테스트 사업장 B"); - String passwordHash = passwordEncoder.encode(PASSWORD); - insertUser(ADMIN_A, COMPANY_A, ADMIN_A_EMAIL, passwordHash, "ADMIN"); - insertUser(HR_A, COMPANY_A, HR_A_EMAIL, passwordHash, "HR"); - insertUser(VIEWER_A, COMPANY_A, VIEWER_A_EMAIL, passwordHash, "VIEWER"); - insertUser(ADMIN_B, COMPANY_B, ADMIN_B_EMAIL, passwordHash, "ADMIN"); - insertWorker(); - insertDraftTask(); } @Test @@ -157,7 +167,7 @@ void approvedTaskCanBeSubmittedEvidencedCompletedAndAudited() throws Exception { HttpResponse activities = authorizedGet(taskPath("/activities"), hrToken); assertThat(activities.statusCode()).isEqualTo(200); List actions = JsonPath.read(activities.body(), "$[*].action"); - assertThat(actions).containsExactly( + assertThat(actions).containsExactlyInAnyOrder( "APPROVAL_REQUESTED", "TASK_APPROVED", "EXTERNAL_SUBMISSION_RECORDED", @@ -219,16 +229,107 @@ void sensitiveSnapshotRollsBackTaskTransitionApprovalAndAuditTogether() throws E } @Test - void viewerCannotWriteAndHrCannotUseAdminAuditSearch() throws Exception { + void adminOnlyPolicyKeepsHrApprovalRequestButRequiresAdminToApprove() throws Exception { + setApprovalPolicy("ADMIN_ONLY"); + String hrToken = accessToken(login(HR_A_EMAIL)); + assertThat(requestApproval(hrToken, validApprovalBody()).statusCode()).isEqualTo(201); + + HttpResponse denied = authorizedPost( + taskPath("/approve"), + """ + {"expected_version":1,"reason":"HR 승인 시도"} + """, + hrToken + ); + + assertThat(denied.statusCode()).isEqualTo(403); + assertThat(JsonPath.read(denied.body(), "$.code")).isEqualTo("ACCESS_DENIED"); + assertThat(taskStatus()).isEqualTo("READY_FOR_REVIEW"); + assertThat(jdbcTemplate.queryForObject( + "SELECT status FROM approval_request WHERE task_id = ?", + String.class, + TASK_A + )).isEqualTo("PENDING"); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM audit_event WHERE target_id = ? AND action = 'TASK_APPROVED'", + Integer.class, + TASK_A + )).isZero(); + + HttpResponse approved = authorizedPost( + taskPath("/approve"), + """ + {"expected_version":1,"reason":"관리자 승인"} + """, + accessToken(login(ADMIN_A_EMAIL)) + ); + + assertThat(approved.statusCode()).isEqualTo(200); + assertThat(taskStatus()).isEqualTo("APPROVED"); + } + + @Test + void adminOnlyPolicyRequiresAdminToReject() throws Exception { + setApprovalPolicy("ADMIN_ONLY"); + String hrToken = accessToken(login(HR_A_EMAIL)); + assertThat(requestApproval(hrToken, validApprovalBody()).statusCode()).isEqualTo(201); + + HttpResponse denied = authorizedPost( + taskPath("/reject"), + """ + {"expected_version":1,"reason":"HR 반려 시도"} + """, + hrToken + ); + + assertThat(denied.statusCode()).isEqualTo(403); + assertThat(taskStatus()).isEqualTo("READY_FOR_REVIEW"); + + HttpResponse rejected = authorizedPost( + taskPath("/reject"), + """ + {"expected_version":1,"reason":"관리자 반려"} + """, + accessToken(login(ADMIN_A_EMAIL)) + ); + + assertThat(rejected.statusCode()).isEqualTo(200); + assertThat(taskStatus()).isEqualTo("DRAFT"); + assertThat(jdbcTemplate.queryForObject( + "SELECT status FROM approval_request WHERE task_id = ?", + String.class, + TASK_A + )).isEqualTo("REJECTED"); + } + + @Test + void viewerCannotWriteAndDefaultAuditVisibilityKeepsSearchAdminOnly() throws Exception { String viewerToken = accessToken(login(VIEWER_A_EMAIL)); HttpResponse viewerWrite = requestApproval(viewerToken, validApprovalBody()); assertThat(viewerWrite.statusCode()).isEqualTo(403); + assertThat(authorizedGet("/api/v1/audit-events", viewerToken).statusCode()).isEqualTo(403); String hrToken = accessToken(login(HR_A_EMAIL)); HttpResponse hrAudit = authorizedGet("/api/v1/audit-events", hrToken); assertThat(hrAudit.statusCode()).isEqualTo(403); } + @Test + void adminAndHrAuditVisibilityAllowsHrCompanyWideSearch() throws Exception { + setAuditVisibility("ADMIN_AND_HR"); + String hrToken = accessToken(login(HR_A_EMAIL)); + assertThat(requestApproval(hrToken, validApprovalBody()).statusCode()).isEqualTo(201); + + HttpResponse response = authorizedGet( + "/api/v1/audit-events?target_type=TASK&target_id=" + TASK_A, + hrToken + ); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(JsonPath.>read(response.body(), "$.items[*].action")) + .containsExactly("APPROVAL_REQUESTED"); + } + @Test void anotherCompanyCannotDiscoverTaskOrActivities() throws Exception { String otherCompanyAdminToken = accessToken(login(ADMIN_B_EMAIL)); @@ -430,6 +531,16 @@ void approvedTaskWithEvidenceCanCompleteWithoutEnteringExternalWaiting() throws """, hrToken ).statusCode()).isEqualTo(200); + HttpResponse withoutEvidence = authorizedPost( + taskPath("/complete"), + """ + {"expected_version":2} + """, + hrToken + ); + assertThat(withoutEvidence.statusCode()).isEqualTo(422); + assertThat(JsonPath.read(withoutEvidence.body(), "$.code")) + .isEqualTo("EVIDENCE_REQUIRED"); assertThat(authorizedPost( taskPath("/evidence"), """ @@ -452,6 +563,66 @@ void approvedTaskWithEvidenceCanCompleteWithoutEnteringExternalWaiting() throws assertThat(count("external_submission")).isZero(); } + @Test + void companyEvidenceRulesAddRequiredTypesWithoutRemovingBaseline() throws Exception { + setEvidenceRules("{\"RECONTRACT\":[\"DOCUMENT\",\"RECEIPT\"]}"); + String hrToken = accessToken(login(HR_A_EMAIL)); + assertThat(requestApproval(hrToken, validApprovalBody()).statusCode()).isEqualTo(201); + assertThat(authorizedPost( + taskPath("/approve"), + """ + {"expected_version":1} + """, + hrToken + ).statusCode()).isEqualTo(200); + HttpResponse withoutEvidence = authorizedPost( + taskPath("/complete"), + """ + {"expected_version":2} + """, + hrToken + ); + assertThat(withoutEvidence.statusCode()).isEqualTo(422); + assertThat(JsonPath.read(withoutEvidence.body(), "$.code")) + .isEqualTo("EVIDENCE_REQUIRED"); + assertThat(authorizedPost( + taskPath("/evidence"), + """ + {"evidence_type":"DOCUMENT","file_reference":"document-ref"} + """, + hrToken + ).statusCode()).isEqualTo(201); + + HttpResponse missingReceipt = authorizedPost( + taskPath("/complete"), + """ + {"expected_version":2} + """, + hrToken + ); + + assertThat(missingReceipt.statusCode()).isEqualTo(422); + assertThat(JsonPath.read(missingReceipt.body(), "$.code")) + .isEqualTo("EVIDENCE_REQUIRED"); + assertThat(taskStatus()).isEqualTo("APPROVED"); + + assertThat(authorizedPost( + taskPath("/evidence"), + """ + {"evidence_type":"RECEIPT","file_reference":"receipt-ref"} + """, + hrToken + ).statusCode()).isEqualTo(201); + assertThat(authorizedPost( + taskPath("/complete"), + """ + {"expected_version":2} + """, + hrToken + ).statusCode()).isEqualTo(200); + assertThat(taskStatus()).isEqualTo("COMPLETED"); + } + private HttpResponse requestApproval(String token, String body) throws Exception { return authorizedPost(taskPath("/approval-requests"), body, token); } @@ -548,6 +719,34 @@ INSERT INTO company ( companyId, name ); + jdbcTemplate.update( + "INSERT INTO company_settings (company_id) VALUES (?)", + companyId + ); + } + + private void setApprovalPolicy(String approvalPolicy) { + jdbcTemplate.update( + "UPDATE company_settings SET approval_policy = ? WHERE company_id = ?", + approvalPolicy, + COMPANY_A + ); + } + + private void setEvidenceRules(String evidenceRulesJson) { + jdbcTemplate.update( + "UPDATE company_settings SET evidence_rules_json = ? WHERE company_id = ?", + evidenceRulesJson, + COMPANY_A + ); + } + + private void setAuditVisibility(String auditVisibility) { + jdbcTemplate.update( + "UPDATE company_settings SET audit_visibility = ? WHERE company_id = ?", + auditVisibility, + COMPANY_A + ); } private void insertUser( diff --git a/src/test/java/com/fowoco/server/auth/SignupIntegrationTest.java b/src/test/java/com/fowoco/server/auth/SignupIntegrationTest.java index b68ac4c2..a583edd6 100644 --- a/src/test/java/com/fowoco/server/auth/SignupIntegrationTest.java +++ b/src/test/java/com/fowoco/server/auth/SignupIntegrationTest.java @@ -89,6 +89,17 @@ void signupCreatesOneCompanyAndInitialAdminThenExistingLoginWorks() throws Excep companyId, "한빛정밀" )).isEqualTo(1); + assertThat(jdbcTemplate.queryForObject( + """ + SELECT COUNT(*) FROM company_settings + WHERE company_id = ? AND approval_policy = 'ADMIN_OR_HR' + AND link_expiry_hours = 72 AND evidence_rules_json = '{}' + AND file_retention_days = 365 AND ai_log_retention_days = 90 + AND audit_visibility = 'ADMIN_ONLY' AND version = 0 + """, + Integer.class, + companyId + )).isEqualTo(1); assertThat(jdbcTemplate.queryForObject( """ SELECT COUNT(*) FROM user_account @@ -159,6 +170,7 @@ void duplicateNormalizedEmailReturnsConflictAndRollsBackNewCompany() throws Exce assertThat(JsonPath.read(duplicate.body(), "$.code")) .isEqualTo("EMAIL_ALREADY_REGISTERED"); assertThat(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM company", Integer.class)).isEqualTo(1); + assertThat(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM company_settings", Integer.class)).isEqualTo(1); assertThat(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM user_account", Integer.class)).isEqualTo(1); assertThat(jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM company WHERE name = '남으면 안 되는 사업장'", @@ -187,6 +199,7 @@ void concurrentSignupWithTheSameEmailLeavesExactlyOneTenant() { assertThat(statuses).containsExactlyInAnyOrder(201, 409); assertThat(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM company", Integer.class)).isEqualTo(1); + assertThat(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM company_settings", Integer.class)).isEqualTo(1); assertThat(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM user_account", Integer.class)).isEqualTo(1); } finally { executor.shutdownNow(); diff --git a/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunnerTest.java b/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunnerTest.java index 1161c1f5..69d101a1 100644 --- a/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunnerTest.java +++ b/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunnerTest.java @@ -7,6 +7,7 @@ import com.fowoco.server.auth.domain.UserAccount; import com.fowoco.server.auth.domain.UserRole; import com.fowoco.server.company.application.port.CompanyRepository; +import com.fowoco.server.company.application.port.CompanySettingsProvisioner; import com.fowoco.server.company.domain.Company; import com.fowoco.server.company.domain.CompanyStatus; import java.time.Clock; @@ -36,10 +37,13 @@ class DemoAuthSeedRunnerTest { void createsAnIdempotentAdminSeedAndStoresOnlyThePasswordHash() throws Exception { InMemoryCompanyRepository companyRepository = new InMemoryCompanyRepository(); InMemoryUserAccountRepository userAccountRepository = new InMemoryUserAccountRepository(); + RecordingCompanySettingsProvisioner settingsProvisioner = + new RecordingCompanySettingsProvisioner(); PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(4); DemoAuthSeedRunner runner = runner( properties(ADMIN_PASSWORD), companyRepository, + settingsProvisioner, userAccountRepository, passwordEncoder ); @@ -48,6 +52,8 @@ void createsAnIdempotentAdminSeedAndStoresOnlyThePasswordHash() throws Exception runner.run(new DefaultApplicationArguments(new String[0])); assertThat(companyRepository.companies).hasSize(2); + assertThat(settingsProvisioner.companyIds) + .containsExactly(COMPANY_ID, TEST_COMPANY_ID); assertThat(companyRepository.companies.get(TEST_COMPANY_ID).name()) .isEqualTo("FOWOCO Test Company"); assertThat(userAccountRepository.users).hasSize(23); @@ -180,16 +186,45 @@ private DemoAuthSeedRunner runner( CompanyRepository companyRepository, UserAccountRepository userAccountRepository, PasswordEncoder passwordEncoder + ) { + return runner( + properties, + companyRepository, + (companyId, now) -> { + }, + userAccountRepository, + passwordEncoder + ); + } + + private DemoAuthSeedRunner runner( + DemoAuthSeedProperties properties, + CompanyRepository companyRepository, + CompanySettingsProvisioner companySettingsProvisioner, + UserAccountRepository userAccountRepository, + PasswordEncoder passwordEncoder ) { return new DemoAuthSeedRunner( properties, companyRepository, + companySettingsProvisioner, userAccountRepository, passwordEncoder, Clock.fixed(NOW, ZoneOffset.UTC) ); } + private static final class RecordingCompanySettingsProvisioner + implements CompanySettingsProvisioner { + + private final java.util.List companyIds = new java.util.ArrayList<>(); + + @Override + public void provisionDefaults(UUID companyId, Instant now) { + companyIds.add(companyId); + } + } + private DemoAuthSeedProperties properties(String password) { return new DemoAuthSeedProperties( true, diff --git a/src/test/java/com/fowoco/server/settings/CompanyMemberSecurityIntegrationTest.java b/src/test/java/com/fowoco/server/settings/CompanyMemberSecurityIntegrationTest.java new file mode 100644 index 00000000..509e2b32 --- /dev/null +++ b/src/test/java/com/fowoco/server/settings/CompanyMemberSecurityIntegrationTest.java @@ -0,0 +1,259 @@ +package com.fowoco.server.settings; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.jayway.jsonpath.JsonPath; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.List; +import java.util.Map; +import java.util.UUID; +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.test.web.server.LocalServerPort; +import org.springframework.http.HttpHeaders; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; + +@ActiveProfiles("test") +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = { + "spring.datasource.url=jdbc:h2:mem:fowoco-company-member-test;" + + "MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;" + + "DEFAULT_NULL_ORDERING=HIGH;DB_CLOSE_DELAY=-1" + } +) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class CompanyMemberSecurityIntegrationTest { + + private static final UUID COMPANY_A = UUID.fromString("71000000-0000-0000-0000-000000000001"); + private static final UUID COMPANY_B = UUID.fromString("72000000-0000-0000-0000-000000000002"); + private static final UUID ADMIN_A = UUID.fromString("71100000-0000-0000-0000-000000000001"); + private static final UUID HR_A = UUID.fromString("71100000-0000-0000-0000-000000000002"); + private static final UUID VIEWER_A = UUID.fromString("71100000-0000-0000-0000-000000000003"); + private static final UUID SUSPENDED_HR_A = UUID.fromString("71100000-0000-0000-0000-000000000004"); + private static final UUID DISABLED_ADMIN_A = UUID.fromString("71100000-0000-0000-0000-000000000005"); + private static final UUID ADMIN_B = UUID.fromString("72100000-0000-0000-0000-000000000001"); + private static final String PASSWORD = "Company-member-password-1!"; + + @LocalServerPort + private int port; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private PasswordEncoder passwordEncoder; + + private final HttpClient httpClient = HttpClient.newHttpClient(); + + @BeforeEach + void resetAndSeed() { + jdbcTemplate.update("DELETE FROM refresh_token"); + jdbcTemplate.update("DELETE FROM audit_event"); + jdbcTemplate.update("DELETE FROM user_account"); + jdbcTemplate.update("DELETE FROM company"); + + insertCompany(COMPANY_A, "구성원 사업장 A"); + insertCompany(COMPANY_B, "구성원 사업장 B"); + insertSettings(COMPANY_A, "ADMIN_OR_HR"); + insertSettings(COMPANY_B, "ADMIN_ONLY"); + String passwordHash = passwordEncoder.encode(PASSWORD); + insertUser(ADMIN_A, COMPANY_A, "member.admin.a@example.com", "가 관리자", "ADMIN", "ACTIVE", passwordHash); + insertUser(HR_A, COMPANY_A, "member.hr.a@example.com", "나 인사", "HR", "ACTIVE", passwordHash); + insertUser(VIEWER_A, COMPANY_A, "member.viewer.a@example.com", "다 조회", "VIEWER", "ACTIVE", passwordHash); + insertUser(SUSPENDED_HR_A, COMPANY_A, "member.suspended.a@example.com", "라 휴면", "HR", "SUSPENDED", passwordHash); + insertUser(DISABLED_ADMIN_A, COMPANY_A, "member.disabled.a@example.com", "마 비활성", "ADMIN", "DISABLED", passwordHash); + insertUser(ADMIN_B, COMPANY_B, "member.admin.b@example.com", "가 타사", "ADMIN", "ACTIVE", passwordHash); + } + + @Test + void adminReceivesActiveMembersInStableOrderWithDerivedApprovalPermission() throws Exception { + HttpResponse response = get(login("member.admin.a@example.com"), ""); + + assertThat(response.statusCode()).isEqualTo(200); + List> items = JsonPath.read(response.body(), "$.items"); + assertThat(items).hasSize(3); + assertThat(JsonPath.>read(response.body(), "$.items[*].display_name")) + .containsExactly("가 관리자", "나 인사", "다 조회"); + assertThat(JsonPath.>read(response.body(), "$.items[0].roles")) + .containsExactly("ADMIN"); + assertThat(JsonPath.read(response.body(), "$.items[0].active")).isTrue(); + assertThat(JsonPath.read(response.body(), "$.items[0].approval_permission")).isTrue(); + assertThat(JsonPath.read(response.body(), "$.items[1].approval_permission")).isTrue(); + assertThat(JsonPath.read(response.body(), "$.items[2].approval_permission")).isFalse(); + assertThat(response.body()).doesNotContain( + "email", + "password", + "normalized_email", + "status", + "version", + COMPANY_B.toString() + ); + } + + @Test + void adminAndHrCanFilterByRoleActiveStateAndDerivedApprovalPermission() throws Exception { + String adminToken = login("member.admin.a@example.com"); + HttpResponse inactiveHr = get( + adminToken, + "?role=HR&approval_capable=false&active_only=false" + ); + assertThat(inactiveHr.statusCode()).isEqualTo(200); + assertThat(JsonPath.>read(inactiveHr.body(), "$.items[*].display_name")) + .containsExactly("라 휴면"); + assertThat(JsonPath.read(inactiveHr.body(), "$.items[0].active")).isFalse(); + assertThat(JsonPath.read( + inactiveHr.body(), + "$.items[0].approval_permission" + )).isFalse(); + + jdbcTemplate.update( + "UPDATE company_settings SET approval_policy = 'ADMIN_ONLY' WHERE company_id = ?", + COMPANY_A + ); + HttpResponse approvalCapable = get( + login("member.hr.a@example.com"), + "?approval_capable=true" + ); + assertThat(JsonPath.>read(approvalCapable.body(), "$.items[*].display_name")) + .containsExactly("가 관리자"); + } + + @Test + void viewerReceivesOnlyMinimalActiveProjectionAndCannotUseRestrictedFilters() throws Exception { + String token = login("member.viewer.a@example.com"); + + HttpResponse response = get(token, ""); + assertThat(response.statusCode()).isEqualTo(200); + assertThat(JsonPath.>read(response.body(), "$.items[*].display_name")) + .containsExactly("가 관리자", "나 인사", "다 조회"); + assertThat(response.body()).doesNotContain( + "roles", + "active", + "approval_permission", + "email", + "status" + ); + assertAccessDenied(get(token, "?role=HR")); + assertAccessDenied(get(token, "?approval_capable=true")); + assertAccessDenied(get(token, "?active_only=false")); + assertThat(get(token, "?active_only=true").statusCode()).isEqualTo(200); + } + + @Test + void queryNeverReturnsAnotherCompanyAndNoMatchReturnsEmptyItems() throws Exception { + String token = login("member.admin.a@example.com"); + + HttpResponse response = get(token, "?role=VIEWER&approval_capable=true"); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(JsonPath.>read(response.body(), "$.items")).isEmpty(); + assertThat(response.body()).doesNotContain(COMPANY_B.toString(), "가 타사"); + } + + @Test + void invalidQueryAndMissingAuthenticationUseTheCommonErrorContract() throws Exception { + String token = login("member.admin.a@example.com"); + assertInvalidRequest(get(token, "?role=OWNER")); + assertInvalidRequest(get(token, "?approval_capable=maybe")); + assertInvalidRequest(get(token, "?active_only=maybe")); + + HttpResponse unauthenticated = httpClient.send( + HttpRequest.newBuilder(uri("/api/v1/company-members")).GET().build(), + HttpResponse.BodyHandlers.ofString() + ); + assertThat(unauthenticated.statusCode()).isEqualTo(401); + assertThat(JsonPath.read(unauthenticated.body(), "$.code")) + .isEqualTo("AUTHENTICATION_REQUIRED"); + } + + private void assertAccessDenied(HttpResponse response) { + assertThat(response.statusCode()).isEqualTo(403); + assertThat(JsonPath.read(response.body(), "$.code")).isEqualTo("ACCESS_DENIED"); + } + + private void assertInvalidRequest(HttpResponse response) { + assertThat(response.statusCode()).isEqualTo(400); + assertThat(JsonPath.read(response.body(), "$.code")) + .isIn("INVALID_REQUEST", "VALIDATION_FAILED"); + } + + private String login(String email) throws Exception { + HttpResponse response = httpClient.send( + HttpRequest.newBuilder(uri("/api/v1/auth/login")) + .header(HttpHeaders.CONTENT_TYPE, "application/json") + .POST(HttpRequest.BodyPublishers.ofString(""" + {"email":"%s","password":"%s"} + """.formatted(email, PASSWORD))) + .build(), + HttpResponse.BodyHandlers.ofString() + ); + assertThat(response.statusCode()).isEqualTo(200); + return JsonPath.read(response.body(), "$.access_token"); + } + + private HttpResponse get(String token, String query) throws Exception { + return httpClient.send( + HttpRequest.newBuilder(uri("/api/v1/company-members" + query)) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .GET() + .build(), + HttpResponse.BodyHandlers.ofString() + ); + } + + private URI uri(String path) { + return URI.create("http://127.0.0.1:" + port + path); + } + + private void insertCompany(UUID companyId, String name) { + jdbcTemplate.update( + "INSERT INTO company (company_id, name, status) VALUES (?, ?, 'ACTIVE')", + companyId, + name + ); + } + + private void insertSettings(UUID companyId, String approvalPolicy) { + jdbcTemplate.update( + "INSERT INTO company_settings (company_id, approval_policy) VALUES (?, ?)", + companyId, + approvalPolicy + ); + } + + private void insertUser( + UUID userId, + UUID companyId, + String email, + String displayName, + String role, + String status, + String passwordHash + ) { + jdbcTemplate.update( + """ + INSERT INTO user_account ( + user_id, company_id, email, normalized_email, + password_hash, role, status, display_name + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + userId, + companyId, + email, + email, + passwordHash, + role, + status, + displayName + ); + } +} diff --git a/src/test/java/com/fowoco/server/settings/CompanySettingsAuditRollbackIntegrationTest.java b/src/test/java/com/fowoco/server/settings/CompanySettingsAuditRollbackIntegrationTest.java new file mode 100644 index 00000000..b0074fb1 --- /dev/null +++ b/src/test/java/com/fowoco/server/settings/CompanySettingsAuditRollbackIntegrationTest.java @@ -0,0 +1,93 @@ +package com.fowoco.server.settings; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.reset; + +import com.fowoco.server.audit.application.port.AuditEventRepository; +import com.fowoco.server.auth.application.ActorContext; +import com.fowoco.server.auth.domain.UserRole; +import com.fowoco.server.common.web.RequestMetadata; +import com.fowoco.server.settings.application.CompanySettingsService; +import com.fowoco.server.settings.application.PatchField; +import com.fowoco.server.settings.application.UpdateCompanySettingsCommand; +import java.util.UUID; +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.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +@ActiveProfiles("test") +@SpringBootTest +class CompanySettingsAuditRollbackIntegrationTest { + + private static final UUID COMPANY_ID = + UUID.fromString("63000000-0000-0000-0000-000000000001"); + private static final UUID ACTOR_ID = + UUID.fromString("63100000-0000-0000-0000-000000000001"); + + @Autowired + private CompanySettingsService companySettingsService; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @MockitoBean + private AuditEventRepository auditEventRepository; + + @BeforeEach + void seedSettings() { + reset(auditEventRepository); + jdbcTemplate.update("DELETE FROM company WHERE company_id = ?", COMPANY_ID); + jdbcTemplate.update( + "INSERT INTO company (company_id, name, status) VALUES (?, 'Audit rollback', 'ACTIVE')", + COMPANY_ID + ); + jdbcTemplate.update( + """ + INSERT INTO company_settings (company_id, link_expiry_hours, version) + VALUES (?, 48, 2) + """, + COMPANY_ID + ); + } + + @Test + void settingsUpdateRollsBackWhenAuditAppendFails() { + doThrow(new IllegalStateException("audit unavailable")) + .when(auditEventRepository) + .append(any()); + UpdateCompanySettingsCommand command = new UpdateCompanySettingsCommand( + 2, + PatchField.absent(), + PatchField.of(72L), + PatchField.absent(), + PatchField.absent(), + PatchField.absent(), + PatchField.absent() + ); + + assertThatThrownBy(() -> companySettingsService.update( + new ActorContext(ACTOR_ID, COMPANY_ID, java.util.Set.of(UserRole.ADMIN)), + command, + new RequestMetadata("settings-audit-rollback", null) + )).isInstanceOf(IllegalStateException.class) + .hasMessage("audit unavailable"); + + assertThat(jdbcTemplate.queryForObject( + "SELECT link_expiry_hours FROM company_settings WHERE company_id = ?", + Long.class, + COMPANY_ID + )).isEqualTo(48L); + assertThat(jdbcTemplate.queryForObject( + "SELECT version FROM company_settings WHERE company_id = ?", + Long.class, + COMPANY_ID + )).isEqualTo(2L); + } +} diff --git a/src/test/java/com/fowoco/server/settings/CompanySettingsMigrationTest.java b/src/test/java/com/fowoco/server/settings/CompanySettingsMigrationTest.java new file mode 100644 index 00000000..d0d6c90b --- /dev/null +++ b/src/test/java/com/fowoco/server/settings/CompanySettingsMigrationTest.java @@ -0,0 +1,67 @@ +package com.fowoco.server.settings; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.UUID; +import org.flywaydb.core.Flyway; +import org.flywaydb.core.api.MigrationVersion; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.datasource.SingleConnectionDataSource; + +class CompanySettingsMigrationTest { + + @Test + void migrationBackfillsFrozenDefaultsForExistingCompanies() throws Exception { + String url = "jdbc:h2:mem:company-settings-migration-" + UUID.randomUUID() + + ";MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH;DB_CLOSE_DELAY=-1"; + try (Connection keepAlive = DriverManager.getConnection(url, "sa", "")) { + SingleConnectionDataSource dataSource = new SingleConnectionDataSource(keepAlive, true); + Flyway.configure() + .dataSource(dataSource) + .locations("classpath:db/migration") + .target(MigrationVersion.fromVersion("32")) + .load() + .migrate(); + + UUID companyId = UUID.fromString("10000000-0000-0000-0000-000000000001"); + try (Statement statement = keepAlive.createStatement()) { + statement.executeUpdate(""" + INSERT INTO company (company_id, name, status) + VALUES ('10000000-0000-0000-0000-000000000001', 'Existing Company', 'ACTIVE') + """); + } + + Flyway flyway = Flyway.configure() + .dataSource(dataSource) + .locations("classpath:db/migration") + .load(); + flyway.migrate(); + flyway.validate(); + + try (var statement = keepAlive.prepareStatement(""" + SELECT approval_policy, link_expiry_hours, evidence_rules_json, + file_retention_days, ai_log_retention_days, + audit_visibility, version + FROM company_settings + WHERE company_id = ? + """)) { + statement.setObject(1, companyId); + try (ResultSet resultSet = statement.executeQuery()) { + assertThat(resultSet.next()).isTrue(); + assertThat(resultSet.getString("approval_policy")).isEqualTo("ADMIN_OR_HR"); + assertThat(resultSet.getLong("link_expiry_hours")).isEqualTo(72L); + assertThat(resultSet.getString("evidence_rules_json")).isEqualTo("{}"); + assertThat(resultSet.getInt("file_retention_days")).isEqualTo(365); + assertThat(resultSet.getInt("ai_log_retention_days")).isEqualTo(90); + assertThat(resultSet.getString("audit_visibility")).isEqualTo("ADMIN_ONLY"); + assertThat(resultSet.getLong("version")).isZero(); + assertThat(resultSet.next()).isFalse(); + } + } + } + } +} diff --git a/src/test/java/com/fowoco/server/settings/CompanySettingsSecurityIntegrationTest.java b/src/test/java/com/fowoco/server/settings/CompanySettingsSecurityIntegrationTest.java new file mode 100644 index 00000000..c36c5d50 --- /dev/null +++ b/src/test/java/com/fowoco/server/settings/CompanySettingsSecurityIntegrationTest.java @@ -0,0 +1,400 @@ +package com.fowoco.server.settings; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.jayway.jsonpath.JsonPath; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +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.test.web.server.LocalServerPort; +import org.springframework.http.HttpHeaders; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; + +@ActiveProfiles("test") +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = { + "spring.datasource.url=jdbc:h2:mem:fowoco-company-settings-get-test;" + + "MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;" + + "DEFAULT_NULL_ORDERING=HIGH;DB_CLOSE_DELAY=-1" + } +) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class CompanySettingsSecurityIntegrationTest { + + private static final UUID COMPANY_A = + UUID.fromString("61000000-0000-0000-0000-000000000001"); + private static final UUID COMPANY_B = + UUID.fromString("62000000-0000-0000-0000-000000000002"); + private static final UUID ADMIN_A = + UUID.fromString("61100000-0000-0000-0000-000000000001"); + private static final UUID HR_A = + UUID.fromString("61100000-0000-0000-0000-000000000002"); + private static final UUID VIEWER_A = + UUID.fromString("61100000-0000-0000-0000-000000000003"); + private static final UUID ADMIN_B = + UUID.fromString("62100000-0000-0000-0000-000000000001"); + private static final String PASSWORD = "Settings-password-1!"; + + @LocalServerPort + private int port; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private PasswordEncoder passwordEncoder; + + private final HttpClient httpClient = HttpClient.newHttpClient(); + + @BeforeEach + void resetAndSeed() { + jdbcTemplate.update("DELETE FROM refresh_token"); + jdbcTemplate.update("DELETE FROM audit_event"); + jdbcTemplate.update("DELETE FROM user_account"); + jdbcTemplate.update("DELETE FROM company"); + + insertCompany(COMPANY_A, "설정 조회 사업장 A"); + insertCompany(COMPANY_B, "설정 조회 사업장 B"); + insertSettings( + COMPANY_A, + "ADMIN_OR_HR", + 48, + "{\"RECONTRACT\":[\"DOCUMENT\"],\"STAY_PERIOD_EXTENSION\":[\"OFFICIAL_RESULT\"]}", + 730, + 120, + "ADMIN_AND_HR", + 4 + ); + insertSettings(COMPANY_B, "ADMIN_ONLY", 24, "{}", 365, 30, "ADMIN_ONLY", 2); + + String passwordHash = passwordEncoder.encode(PASSWORD); + insertUser(ADMIN_A, COMPANY_A, "settings.admin.a@example.com", "ADMIN", passwordHash); + insertUser(HR_A, COMPANY_A, "settings.hr.a@example.com", "HR", passwordHash); + insertUser(VIEWER_A, COMPANY_A, "settings.viewer.a@example.com", "VIEWER", passwordHash); + insertUser(ADMIN_B, COMPANY_B, "settings.admin.b@example.com", "ADMIN", passwordHash); + } + + @Test + void adminHrAndViewerReceiveTheSamePublicSettingsDtoWithoutSensitiveFields() throws Exception { + HttpResponse admin = get(login("settings.admin.a@example.com")); + HttpResponse hr = get(login("settings.hr.a@example.com")); + HttpResponse viewer = get(login("settings.viewer.a@example.com")); + + assertThat(admin.statusCode()).isEqualTo(200); + assertThat(hr.statusCode()).isEqualTo(200); + assertThat(viewer.statusCode()).isEqualTo(200); + Map adminBody = JsonPath.read(admin.body(), "$"); + assertThat(JsonPath.>read(hr.body(), "$")).isEqualTo(adminBody); + assertThat(JsonPath.>read(viewer.body(), "$")).isEqualTo(adminBody); + assertThat(JsonPath.read(admin.body(), "$.approval_policy")) + .isEqualTo("ADMIN_OR_HR"); + assertThat(JsonPath.read(admin.body(), "$.link_expiry_hours").longValue()) + .isEqualTo(48L); + assertThat(JsonPath.read(admin.body(), "$.evidence_rules.RECONTRACT[0]")) + .isEqualTo("DOCUMENT"); + assertThat(JsonPath.read(admin.body(), "$.file_retention_days").intValue()) + .isEqualTo(730); + assertThat(JsonPath.read(admin.body(), "$.ai_log_retention_days").intValue()) + .isEqualTo(120); + assertThat(JsonPath.read(admin.body(), "$.audit_visibility")) + .isEqualTo("ADMIN_AND_HR"); + assertThat(JsonPath.read(admin.body(), "$.version").longValue()).isEqualTo(4L); + assertThat(admin.body()).doesNotContain( + "company_id", + "email", + "password", + "token", + "secret", + "created_at", + "updated_at" + ); + } + + @Test + void settingsAreResolvedOnlyFromTheAuthenticatedCompany() throws Exception { + HttpResponse companyA = get(login("settings.admin.a@example.com")); + HttpResponse companyB = get(login("settings.admin.b@example.com")); + + assertThat(JsonPath.read(companyA.body(), "$.link_expiry_hours").longValue()) + .isEqualTo(48L); + assertThat(JsonPath.read(companyB.body(), "$.link_expiry_hours").longValue()) + .isEqualTo(24L); + assertThat(JsonPath.read(companyB.body(), "$.approval_policy")) + .isEqualTo("ADMIN_ONLY"); + assertThat(companyA.body()).doesNotContain(COMPANY_B.toString()); + assertThat(companyB.body()).doesNotContain(COMPANY_A.toString()); + } + + @Test + void unauthenticatedRequestIsRejected() throws Exception { + HttpResponse response = httpClient.send( + HttpRequest.newBuilder(uri("/api/v1/settings")).GET().build(), + HttpResponse.BodyHandlers.ofString() + ); + + assertThat(response.statusCode()).isEqualTo(401); + assertThat(JsonPath.read(response.body(), "$.code")) + .isEqualTo("AUTHENTICATION_REQUIRED"); + } + + @Test + void missingPersistedSettingsRowIsAnInternalInvariantViolation() throws Exception { + String token = login("settings.admin.a@example.com"); + jdbcTemplate.update("DELETE FROM company_settings WHERE company_id = ?", COMPANY_A); + + HttpResponse response = get(token); + + assertThat(response.statusCode()).isEqualTo(500); + assertThat(JsonPath.read(response.body(), "$.code")) + .isEqualTo("INTERNAL_SERVER_ERROR"); + assertThat(response.body()).doesNotContain(COMPANY_A.toString(), "Persisted company settings"); + } + + @Test + void adminPartiallyUpdatesSettingsAndRecordsCompactBeforeAfterAudit() throws Exception { + String token = login("settings.admin.a@example.com"); + + HttpResponse response = patch(token, """ + { + "expected_version":4, + "approval_policy":"ADMIN_ONLY", + "link_expiry_hours":72, + "evidence_rules":{"RECONTRACT":["DOCUMENT","RECEIPT"]} + } + """, "settings-patch-audit-test"); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(JsonPath.read(response.body(), "$.approval_policy")) + .isEqualTo("ADMIN_ONLY"); + assertThat(JsonPath.read(response.body(), "$.link_expiry_hours").longValue()) + .isEqualTo(72L); + assertThat(JsonPath.read(response.body(), "$.file_retention_days").intValue()) + .isEqualTo(730); + assertThat(JsonPath.read(response.body(), "$.version").longValue()).isEqualTo(5L); + + List> audits = jdbcTemplate.queryForList( + """ + SELECT action, target_type, target_id, actor_id, user_role, + request_id, event_version, change_summary + FROM audit_event + WHERE company_id = ? + ORDER BY audit_event_id + """, + COMPANY_A + ); + assertThat(audits).hasSize(4).allSatisfy(audit -> assertThat(audit) + .containsEntry("action", "SETTINGS_UPDATED") + .containsEntry("target_type", "COMPANY_SETTINGS") + .containsEntry("target_id", COMPANY_A) + .containsEntry("actor_id", ADMIN_A) + .containsEntry("user_role", "ADMIN") + .containsEntry("request_id", "settings-patch-audit-test") + .containsEntry("event_version", "1")); + assertThat(audits.stream().map(audit -> audit.get("change_summary").toString())) + .containsExactlyInAnyOrder( + "approval_policy:ADMIN_OR_HR->ADMIN_ONLY,version:4->5", + "link_expiry_hours:48->72,version:4->5", + "evidence_rules.RECONTRACT:[DOCUMENT]->[DOCUMENT,RECEIPT],version:4->5", + "evidence_rules.STAY_PERIOD_EXTENSION:[OFFICIAL_RESULT]->[],version:4->5" + ); + } + + @Test + void noOpKeepsVersionWithoutAuditAndStaleVersionReturnsConflict() throws Exception { + String token = login("settings.admin.a@example.com"); + + HttpResponse noOp = patch(token, "{\"expected_version\":4}"); + HttpResponse stale = patch(token, """ + {"expected_version":3,"link_expiry_hours":72} + """); + + assertThat(noOp.statusCode()).isEqualTo(200); + assertThat(JsonPath.read(noOp.body(), "$.version").longValue()).isEqualTo(4L); + assertThat(stale.statusCode()).isEqualTo(409); + assertThat(JsonPath.read(stale.body(), "$.code")) + .isEqualTo("CONCURRENT_MODIFICATION"); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM audit_event WHERE company_id = ?", + Integer.class, + COMPANY_A + )).isZero(); + } + + @Test + void patchRejectsExplicitNullUnknownFieldsMissingVersionAndOutOfRangeValues() throws Exception { + String token = login("settings.admin.a@example.com"); + + assertThat(patch(token, "{\"expected_version\":4,\"approval_policy\":null}").statusCode()) + .isEqualTo(400); + assertThat(patch(token, "{\"expected_version\":4,\"company_id\":\"" + COMPANY_B + + "\"}").statusCode()).isEqualTo(400); + assertThat(patch(token, "{}").statusCode()).isEqualTo(400); + assertThat(patch(token, "{\"expected_version\":4,\"link_expiry_hours\":169}") + .statusCode()).isEqualTo(400); + assertThat(patch(token, """ + {"expected_version":4,"evidence_rules":{"RECONTRACT":["DOCUMENT","DOCUMENT"]}} + """).statusCode()).isEqualTo(400); + } + + @Test + void hrAndViewerCannotPatchSettings() throws Exception { + String body = "{\"expected_version\":4,\"link_expiry_hours\":72}"; + + assertThat(patch(login("settings.hr.a@example.com"), body).statusCode()).isEqualTo(403); + assertThat(patch(login("settings.viewer.a@example.com"), body).statusCode()).isEqualTo(403); + } + + @Test + void concurrentPatchAllowsOnlyOneWriterForTheExpectedVersion() throws Exception { + String token = login("settings.admin.a@example.com"); + CompletableFuture> first = CompletableFuture.supplyAsync( + () -> patchUnchecked(token, "{\"expected_version\":4,\"link_expiry_hours\":72}") + ); + CompletableFuture> second = CompletableFuture.supplyAsync( + () -> patchUnchecked(token, "{\"expected_version\":4,\"link_expiry_hours\":96}") + ); + + assertThat(List.of(first.join().statusCode(), second.join().statusCode())) + .containsExactlyInAnyOrder(200, 409); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM audit_event WHERE company_id = ?", + Integer.class, + COMPANY_A + )).isEqualTo(1); + } + + private String login(String email) throws Exception { + HttpResponse response = httpClient.send( + HttpRequest.newBuilder(uri("/api/v1/auth/login")) + .header(HttpHeaders.CONTENT_TYPE, MediaTypeValues.APPLICATION_JSON) + .POST(HttpRequest.BodyPublishers.ofString(""" + {"email":"%s","password":"%s"} + """.formatted(email, PASSWORD))) + .build(), + HttpResponse.BodyHandlers.ofString() + ); + assertThat(response.statusCode()).isEqualTo(200); + return JsonPath.read(response.body(), "$.access_token"); + } + + private HttpResponse get(String token) throws Exception { + return httpClient.send( + HttpRequest.newBuilder(uri("/api/v1/settings")) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .GET() + .build(), + HttpResponse.BodyHandlers.ofString() + ); + } + + private HttpResponse patch(String token, String body) throws Exception { + return patch(token, body, null); + } + + private HttpResponse patch(String token, String body, String requestId) throws Exception { + HttpRequest.Builder request = HttpRequest.newBuilder(uri("/api/v1/settings")) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .header(HttpHeaders.CONTENT_TYPE, MediaTypeValues.APPLICATION_JSON); + if (requestId != null) { + request.header("X-Request-Id", requestId); + } + return httpClient.send( + request + .method("PATCH", HttpRequest.BodyPublishers.ofString(body)) + .build(), + HttpResponse.BodyHandlers.ofString() + ); + } + + private HttpResponse patchUnchecked(String token, String body) { + try { + return patch(token, body); + } catch (Exception exception) { + throw new IllegalStateException(exception); + } + } + + private URI uri(String path) { + return URI.create("http://127.0.0.1:" + port + path); + } + + private void insertCompany(UUID companyId, String name) { + jdbcTemplate.update( + "INSERT INTO company (company_id, name, status) VALUES (?, ?, 'ACTIVE')", + companyId, + name + ); + } + + private void insertSettings( + UUID companyId, + String approvalPolicy, + long linkExpiryHours, + String evidenceRulesJson, + int fileRetentionDays, + int aiLogRetentionDays, + String auditVisibility, + long version + ) { + jdbcTemplate.update( + """ + INSERT INTO company_settings ( + company_id, approval_policy, link_expiry_hours, evidence_rules_json, + file_retention_days, ai_log_retention_days, audit_visibility, version + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + companyId, + approvalPolicy, + linkExpiryHours, + evidenceRulesJson, + fileRetentionDays, + aiLogRetentionDays, + auditVisibility, + version + ); + } + + private void insertUser( + UUID userId, + UUID companyId, + String email, + String role, + String passwordHash + ) { + jdbcTemplate.update( + """ + INSERT INTO user_account ( + user_id, company_id, email, normalized_email, + password_hash, role, status, display_name + ) VALUES (?, ?, ?, ?, ?, ?, 'ACTIVE', ?) + """, + userId, + companyId, + email, + email, + passwordHash, + role, + role + " 설정 조회자" + ); + } + + private static final class MediaTypeValues { + private static final String APPLICATION_JSON = "application/json"; + + private MediaTypeValues() { + } + } +} diff --git a/src/test/java/com/fowoco/server/settings/PostgreSqlCompanyMemberRlsTest.java b/src/test/java/com/fowoco/server/settings/PostgreSqlCompanyMemberRlsTest.java new file mode 100644 index 00000000..24db80d6 --- /dev/null +++ b/src/test/java/com/fowoco/server/settings/PostgreSqlCompanyMemberRlsTest.java @@ -0,0 +1,156 @@ +package com.fowoco.server.settings; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fowoco.server.common.security.PostgreSqlRlsStateFixture; +import com.fowoco.server.common.security.PostgreSqlRlsTestLock; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.List; +import java.util.UUID; +import org.flywaydb.core.Flyway; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +@EnabledIfEnvironmentVariable(named = "POSTGRES_TEST_ENABLED", matches = "true") +class PostgreSqlCompanyMemberRlsTest { + + private static final UUID COMPANY_A = UUID.fromString("d1000000-0000-0000-0000-000000000001"); + private static final UUID COMPANY_B = UUID.fromString("d2000000-0000-0000-0000-000000000002"); + + @Test + void userAccountDirectoryFailsClosedAndOnlyReadsTheBoundCompany() throws Exception { + String url = required("POSTGRES_TEST_URL"); + String migrationUser = required("POSTGRES_TEST_USERNAME"); + String migrationPassword = required("POSTGRES_TEST_PASSWORD"); + String runtimeRole = "company_member_rls_" + + UUID.randomUUID().toString().replace("-", "").substring(0, 10); + String runtimePassword = "Company-member-" + UUID.randomUUID(); + + try (PostgreSqlRlsTestLock ignored = PostgreSqlRlsTestLock.acquire( + url, + migrationUser, + migrationPassword + )) { + Flyway.configure() + .dataSource(url, migrationUser, migrationPassword) + .locations("classpath:db/migration", "classpath:db/migration-postgresql") + .load() + .migrate(); + try (Connection admin = DriverManager.getConnection(url, migrationUser, migrationPassword); + PostgreSqlRlsStateFixture state = PostgreSqlRlsStateFixture.capture( + admin, + List.of("user_account") + )) { + state.disableRowLevelSecurityForFixtureSetup(); + prepare(admin, runtimeRole, runtimePassword); + state.enableRowLevelSecurity(); + try (Connection runtime = DriverManager.getConnection( + url, + runtimeRole, + runtimePassword + )) { + runtime.setAutoCommit(false); + assertThat(count(runtime)).isZero(); + runtime.rollback(); + + bind(runtime, COMPANY_A); + assertThat(count(runtime)).isEqualTo(2); + runtime.rollback(); + + bind(runtime, COMPANY_B); + assertThat(count(runtime)).isEqualTo(1); + runtime.rollback(); + } finally { + state.disableRowLevelSecurityForFixtureSetup(); + cleanup(admin, runtimeRole); + } + } + } + } + + private void prepare(Connection connection, String role, String password) throws SQLException { + try (Statement statement = connection.createStatement()) { + cleanupRows(statement); + statement.execute("CREATE ROLE " + quoteIdentifier(role) + + " LOGIN PASSWORD " + quoteLiteral(password) + + " NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS"); + statement.execute("GRANT CONNECT ON DATABASE " + quoteIdentifier(connection.getCatalog()) + + " TO " + quoteIdentifier(role)); + statement.execute("GRANT USAGE ON SCHEMA public TO " + quoteIdentifier(role)); + statement.execute("GRANT SELECT ON TABLE public.user_account TO " + + quoteIdentifier(role)); + statement.execute("INSERT INTO company (company_id, name, status) VALUES " + + "('" + COMPANY_A + "', 'Member RLS A', 'ACTIVE')," + + "('" + COMPANY_B + "', 'Member RLS B', 'ACTIVE')"); + statement.execute(""" + INSERT INTO user_account ( + user_id, company_id, email, normalized_email, + password_hash, role, status, display_name + ) VALUES + ('d1100000-0000-0000-0000-000000000001', + 'd1000000-0000-0000-0000-000000000001', + 'member.rls.a1@example.com', 'member.rls.a1@example.com', + 'hash-a1', 'ADMIN', 'ACTIVE', 'RLS A1'), + ('d1100000-0000-0000-0000-000000000002', + 'd1000000-0000-0000-0000-000000000001', + 'member.rls.a2@example.com', 'member.rls.a2@example.com', + 'hash-a2', 'HR', 'SUSPENDED', 'RLS A2'), + ('d2100000-0000-0000-0000-000000000001', + 'd2000000-0000-0000-0000-000000000002', + 'member.rls.b1@example.com', 'member.rls.b1@example.com', + 'hash-b1', 'VIEWER', 'ACTIVE', 'RLS B1') + """); + } + } + + private void bind(Connection connection, UUID companyId) throws SQLException { + try (var statement = connection.prepareStatement( + "SELECT set_config('app.company_id', ?, true)" + )) { + statement.setString(1, companyId.toString()); + statement.execute(); + } + } + + private int count(Connection connection) throws SQLException { + try (Statement statement = connection.createStatement(); + var result = statement.executeQuery("SELECT COUNT(*) FROM user_account")) { + result.next(); + return result.getInt(1); + } + } + + private void cleanup(Connection connection, String role) throws SQLException { + try (Statement statement = connection.createStatement()) { + cleanupRows(statement); + statement.execute("DROP OWNED BY " + quoteIdentifier(role)); + statement.execute("DROP ROLE IF EXISTS " + quoteIdentifier(role)); + } + } + + private void cleanupRows(Statement statement) throws SQLException { + statement.execute("DELETE FROM user_account WHERE company_id IN ('" + + COMPANY_A + "','" + COMPANY_B + "')"); + statement.execute("DELETE FROM company WHERE company_id IN ('" + + COMPANY_A + "','" + COMPANY_B + "')"); + } + + private String required(String name) { + String value = System.getenv(name); + if (value == null || value.isBlank()) { + throw new IllegalStateException(name + " is required"); + } + return value; + } + + private String quoteIdentifier(String value) { + return "\"" + value.replace("\"", "\"\"") + "\""; + } + + private String quoteLiteral(String value) { + return "'" + value.replace("'", "''") + "'"; + } +} diff --git a/src/test/java/com/fowoco/server/settings/PostgreSqlCompanySettingsRlsTest.java b/src/test/java/com/fowoco/server/settings/PostgreSqlCompanySettingsRlsTest.java new file mode 100644 index 00000000..864ab57b --- /dev/null +++ b/src/test/java/com/fowoco/server/settings/PostgreSqlCompanySettingsRlsTest.java @@ -0,0 +1,146 @@ +package com.fowoco.server.settings; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fowoco.server.common.security.PostgreSqlRlsStateFixture; +import com.fowoco.server.common.security.PostgreSqlRlsTestLock; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.List; +import java.util.UUID; +import org.flywaydb.core.Flyway; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +@EnabledIfEnvironmentVariable(named = "POSTGRES_TEST_ENABLED", matches = "true") +class PostgreSqlCompanySettingsRlsTest { + + private static final UUID COMPANY_A = UUID.fromString("c1000000-0000-0000-0000-000000000001"); + private static final UUID COMPANY_B = UUID.fromString("c2000000-0000-0000-0000-000000000002"); + private static final UUID COMPANY_C = UUID.fromString("c3000000-0000-0000-0000-000000000003"); + + @Test + void companySettingsFailClosedAndRejectCrossTenantWrites() throws Exception { + String url = required("POSTGRES_TEST_URL"); + String migrationUser = required("POSTGRES_TEST_USERNAME"); + String migrationPassword = required("POSTGRES_TEST_PASSWORD"); + String runtimeRole = "company_settings_rls_" + + UUID.randomUUID().toString().replace("-", "").substring(0, 10); + String runtimePassword = "Company-settings-" + UUID.randomUUID(); + + try (PostgreSqlRlsTestLock ignored = PostgreSqlRlsTestLock.acquire( + url, + migrationUser, + migrationPassword + )) { + Flyway.configure() + .dataSource(url, migrationUser, migrationPassword) + .locations("classpath:db/migration", "classpath:db/migration-postgresql") + .load() + .migrate(); + try (Connection admin = DriverManager.getConnection(url, migrationUser, migrationPassword); + PostgreSqlRlsStateFixture state = PostgreSqlRlsStateFixture.capture( + admin, + List.of("company_settings") + )) { + state.disableRowLevelSecurityForFixtureSetup(); + prepare(admin, runtimeRole, runtimePassword); + state.enableRowLevelSecurity(); + try (Connection runtime = DriverManager.getConnection( + url, + runtimeRole, + runtimePassword + )) { + runtime.setAutoCommit(false); + assertThat(count(runtime)).isZero(); + runtime.rollback(); + + bind(runtime, COMPANY_A); + assertThat(count(runtime)).isEqualTo(1); + runtime.rollback(); + + bind(runtime, COMPANY_A); + assertThatThrownBy(() -> runtime.createStatement().executeUpdate(""" + INSERT INTO company_settings (company_id) + VALUES ('c3000000-0000-0000-0000-000000000003') + """)) + .isInstanceOf(SQLException.class) + .hasMessageContaining("row-level security"); + runtime.rollback(); + } finally { + state.disableRowLevelSecurityForFixtureSetup(); + cleanup(admin, runtimeRole); + } + } + } + } + + private void prepare(Connection connection, String role, String password) throws SQLException { + try (Statement statement = connection.createStatement()) { + cleanupRows(statement); + statement.execute("CREATE ROLE " + quoteIdentifier(role) + + " LOGIN PASSWORD " + quoteLiteral(password) + + " NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS"); + statement.execute("GRANT CONNECT ON DATABASE " + quoteIdentifier(connection.getCatalog()) + + " TO " + quoteIdentifier(role)); + statement.execute("GRANT USAGE ON SCHEMA public TO " + quoteIdentifier(role)); + statement.execute("GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE public.company_settings TO " + + quoteIdentifier(role)); + statement.execute("INSERT INTO company (company_id, name, status) VALUES " + + "('" + COMPANY_A + "', 'Settings RLS A', 'ACTIVE')," + + "('" + COMPANY_B + "', 'Settings RLS B', 'ACTIVE')," + + "('" + COMPANY_C + "', 'Settings RLS C', 'ACTIVE')"); + statement.execute("INSERT INTO company_settings (company_id) VALUES " + + "('" + COMPANY_A + "'),('" + COMPANY_B + "')"); + } + } + + private void bind(Connection connection, UUID companyId) throws SQLException { + try (var statement = connection.prepareStatement( + "SELECT set_config('app.company_id', ?, true)" + )) { + statement.setString(1, companyId.toString()); + statement.execute(); + } + } + + private int count(Connection connection) throws SQLException { + try (Statement statement = connection.createStatement(); + var result = statement.executeQuery("SELECT COUNT(*) FROM company_settings")) { + result.next(); + return result.getInt(1); + } + } + + private void cleanup(Connection connection, String role) throws SQLException { + try (Statement statement = connection.createStatement()) { + cleanupRows(statement); + statement.execute("DROP OWNED BY " + quoteIdentifier(role)); + statement.execute("DROP ROLE IF EXISTS " + quoteIdentifier(role)); + } + } + + private void cleanupRows(Statement statement) throws SQLException { + statement.execute("DELETE FROM company WHERE company_id IN ('" + + COMPANY_A + "','" + COMPANY_B + "','" + COMPANY_C + "')"); + } + + private String required(String name) { + String value = System.getenv(name); + if (value == null || value.isBlank()) { + throw new IllegalStateException(name + " is required"); + } + return value; + } + + private String quoteIdentifier(String value) { + return "\"" + value.replace("\"", "\"\"") + "\""; + } + + private String quoteLiteral(String value) { + return "'" + value.replace("'", "''") + "'"; + } +} diff --git a/src/test/java/com/fowoco/server/settings/api/CompanyMemberOpenApiContractTest.java b/src/test/java/com/fowoco/server/settings/api/CompanyMemberOpenApiContractTest.java new file mode 100644 index 00000000..4be71f16 --- /dev/null +++ b/src/test/java/com/fowoco/server/settings/api/CompanyMemberOpenApiContractTest.java @@ -0,0 +1,124 @@ +package com.fowoco.server.settings.api; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.context.ActiveProfiles; + +@ActiveProfiles("test") +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class CompanyMemberOpenApiContractTest { + + @LocalServerPort + private int port; + + private JsonNode openApi; + + @BeforeAll + void loadOpenApi() throws Exception { + HttpResponse response = HttpClient.newHttpClient().send( + HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + "/v3/api-docs")) + .GET() + .build(), + HttpResponse.BodyHandlers.ofString() + ); + assertThat(response.statusCode()).isEqualTo(200); + openApi = new ObjectMapper().readTree(response.body()); + } + + @Test + void operationPublishesFrozenQuerySecurityAndErrors() { + JsonNode operation = openApi.at("/paths/~1api~1v1~1company-members/get"); + Map parameters = StreamSupport.stream( + operation.path("parameters").spliterator(), + false + ) + .collect(Collectors.toMap( + parameter -> parameter.path("name").asText(), + parameter -> parameter + )); + + assertThat(operation.path("operationId").asText()).isEqualTo("listCompanyMembers"); + assertThat(operation.at("/security/0/bearerAuth").isArray()).isTrue(); + assertThat(parameters.keySet()).containsExactlyInAnyOrder( + "X-Request-Id", + "role", + "approval_capable", + "active_only" + ); + assertThat(parameters.get("role").path("required").asBoolean()).isFalse(); + assertThat(parameters.get("approval_capable").path("required").asBoolean()).isFalse(); + assertThat(parameters.get("active_only").at("/schema/default").asBoolean()).isTrue(); + assertThat(operation.at("/responses/400/$ref").asText()) + .isEqualTo("#/components/responses/BadRequest"); + assertThat(operation.at("/responses/401/$ref").asText()) + .isEqualTo("#/components/responses/Unauthorized"); + assertThat(operation.at("/responses/403/$ref").asText()) + .isEqualTo("#/components/responses/Forbidden"); + } + + @Test + void responseSchemasExposeDetailedAndMinimalProjectionsWithoutSensitiveFields() { + JsonNode envelope = openApi.at("/components/schemas/CompanyMemberListResponse"); + JsonNode item = openApi.at("/components/schemas/CompanyMemberItemResponse"); + JsonNode detailed = openApi.at("/components/schemas/DetailedCompanyMemberResponse"); + JsonNode minimal = openApi.at("/components/schemas/MinimalCompanyMemberResponse"); + + assertThat(envelope.path("required")) + .extracting(JsonNode::asText) + .containsExactly("items"); + assertThat(item.path("oneOf")).hasSize(2); + assertThat(propertyNames(detailed)).containsExactlyInAnyOrder( + "user_id", + "display_name", + "roles", + "active", + "approval_permission" + ); + assertThat(propertyNames(minimal)).containsExactlyInAnyOrder("user_id", "display_name"); + assertThat(detailed.path("required")).hasSize(5); + assertThat(minimal.path("required")).hasSize(2); + assertThat(detailed.toString()).doesNotContain( + "email", + "password", + "status", + "version", + "secret" + ); + } + + @Test + void successResponsePublishesBothRoleExamples() { + JsonNode examples = openApi.at( + "/paths/~1api~1v1~1company-members/get/responses/200/content/application~1json/examples" + ); + + assertThat(examples.at("/adminOrHr/value/items/0/approval_permission").asBoolean()).isTrue(); + assertThat(examples.at("/viewer/value/items/0/display_name").asText()).isEqualTo("김인사"); + assertThat(examples.at("/viewer/value/items/0").has("roles")).isFalse(); + } + + private Set propertyNames(JsonNode schema) { + JsonNode properties = schema.path("properties"); + return StreamSupport.stream( + ((Iterable) properties::fieldNames).spliterator(), + false + ) + .collect(Collectors.toSet()); + } +} diff --git a/src/test/java/com/fowoco/server/settings/api/CompanySettingsOpenApiContractTest.java b/src/test/java/com/fowoco/server/settings/api/CompanySettingsOpenApiContractTest.java new file mode 100644 index 00000000..286cbdba --- /dev/null +++ b/src/test/java/com/fowoco/server/settings/api/CompanySettingsOpenApiContractTest.java @@ -0,0 +1,162 @@ +package com.fowoco.server.settings.api; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.context.ActiveProfiles; + +@ActiveProfiles("test") +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class CompanySettingsOpenApiContractTest { + + @LocalServerPort + private int port; + + private JsonNode openApi; + + @BeforeAll + void loadOpenApi() throws Exception { + HttpResponse response = HttpClient.newHttpClient().send( + HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + "/v3/api-docs")) + .GET() + .build(), + HttpResponse.BodyHandlers.ofString() + ); + assertThat(response.statusCode()).isEqualTo(200); + openApi = new ObjectMapper().readTree(response.body()); + } + + @Test + void settingsGetPublishesBearerSecurityAndErrorResponses() { + JsonNode operation = openApi.at("/paths/~1api~1v1~1settings/get"); + + assertThat(operation.path("operationId").asText()).isEqualTo("getCompanySettings"); + assertThat(operation.at("/security/0/bearerAuth").isArray()).isTrue(); + assertThat(operation.at("/responses/401/$ref").asText()) + .isEqualTo("#/components/responses/Unauthorized"); + assertThat(operation.at("/responses/403/$ref").asText()) + .isEqualTo("#/components/responses/Forbidden"); + assertThat(operation.at("/responses/500/$ref").asText()) + .isEqualTo("#/components/responses/InternalServerError"); + assertThat(operation.path("requestBody").isMissingNode()).isTrue(); + JsonNode parameters = operation.path("parameters"); + assertThat(parameters).hasSize(1); + JsonNode requestIdHeader = parameters.path(0); + assertThat(requestIdHeader.path("name").asText()).isEqualTo("X-Request-Id"); + assertThat(requestIdHeader.path("in").asText()).isEqualTo("header"); + assertThat(requestIdHeader.path("required").asBoolean()).isFalse(); + } + + @Test + void responseSchemaIsTheFrozenSnakeCasePublicDto() { + JsonNode schema = openApi.at("/components/schemas/CompanySettingsResponse"); + JsonNode properties = schema.path("properties"); + Set propertyNames = StreamSupport.stream( + ((Iterable) properties::fieldNames).spliterator(), + false + ) + .collect(Collectors.toSet()); + + assertThat(propertyNames).containsExactlyInAnyOrder( + "approval_policy", + "link_expiry_hours", + "evidence_rules", + "file_retention_days", + "ai_log_retention_days", + "audit_visibility", + "version" + ); + assertThat(schema.path("required")).hasSize(7); + assertThat(properties.path("approval_policy").path("enum")) + .extracting(JsonNode::asText) + .containsExactly("ADMIN_ONLY", "ADMIN_OR_HR"); + assertThat(properties.path("audit_visibility").path("enum")) + .extracting(JsonNode::asText) + .containsExactly("ADMIN_ONLY", "ADMIN_AND_HR"); + assertThat(properties.path("link_expiry_hours").path("minimum").asLong()).isEqualTo(1); + assertThat(properties.path("link_expiry_hours").path("maximum").asLong()).isEqualTo(168); + assertThat(properties.has("company_id")).isFalse(); + assertThat(properties.has("email")).isFalse(); + assertThat(properties.has("secret")).isFalse(); + } + + @Test + void successResponseIncludesTheDocumentedExample() { + JsonNode example = openApi.at( + "/paths/~1api~1v1~1settings/get/responses/200/content/application~1json/examples/settings/value" + ); + + assertThat(example.path("approval_policy").asText()).isEqualTo("ADMIN_OR_HR"); + assertThat(example.path("link_expiry_hours").asLong()).isEqualTo(72L); + assertThat(example.at("/evidence_rules/RECONTRACT/0").asText()).isEqualTo("DOCUMENT"); + assertThat(example.path("audit_visibility").asText()).isEqualTo("ADMIN_ONLY"); + } + + @Test + void settingsPatchPublishesFrozenRequestAndErrorContract() { + JsonNode operation = openApi.at("/paths/~1api~1v1~1settings/patch"); + JsonNode schema = openApi.at("/components/schemas/CompanySettingsPatchRequest"); + JsonNode properties = schema.path("properties"); + + assertThat(operation.path("operationId").asText()).isEqualTo("updateCompanySettings"); + assertThat(operation.at("/security/0/bearerAuth").isArray()).isTrue(); + assertThat(operation.at("/responses/400/$ref").asText()) + .isEqualTo("#/components/responses/BadRequest"); + assertThat(operation.at("/responses/409/$ref").asText()) + .isEqualTo("#/components/responses/Conflict"); + assertThat(operation.at( + "/requestBody/content/application~1json/schema/$ref" + ).asText()).isEqualTo("#/components/schemas/CompanySettingsPatchRequest"); + assertThat(schema.path("additionalProperties").asBoolean()).isFalse(); + assertThat(schema.path("required")) + .extracting(JsonNode::asText) + .containsExactly("expected_version"); + Set patchPropertyNames = StreamSupport.stream( + ((Iterable) properties::fieldNames).spliterator(), + false + ) + .collect(Collectors.toSet()); + assertThat(patchPropertyNames) + .containsExactlyInAnyOrder( + "expected_version", + "approval_policy", + "link_expiry_hours", + "evidence_rules", + "file_retention_days", + "ai_log_retention_days", + "audit_visibility" + ); + assertThat(properties.path("link_expiry_hours").path("minimum").asLong()).isEqualTo(1L); + assertThat(properties.path("link_expiry_hours").path("maximum").asLong()).isEqualTo(168L); + + JsonNode requestExample = operation.at( + "/requestBody/content/application~1json/examples/partialUpdate/value" + ); + assertThat(requestExample.path("expected_version").asLong()).isEqualTo(4L); + assertThat(requestExample.path("approval_policy").asText()).isEqualTo("ADMIN_ONLY"); + assertThat(requestExample.path("link_expiry_hours").asLong()).isEqualTo(48L); + assertThat(requestExample.path("audit_visibility").asText()).isEqualTo("ADMIN_AND_HR"); + + JsonNode responseExample = operation.at( + "/responses/200/content/application~1json/examples/updatedSettings/value" + ); + assertThat(responseExample.path("version").asLong()).isEqualTo(5L); + assertThat(responseExample.path("approval_policy").asText()).isEqualTo("ADMIN_ONLY"); + assertThat(responseExample.at("/evidence_rules/RECONTRACT/0").asText()) + .isEqualTo("DOCUMENT"); + } +} diff --git a/src/test/java/com/fowoco/server/settings/application/CompanySettingsAuditSummaryTest.java b/src/test/java/com/fowoco/server/settings/application/CompanySettingsAuditSummaryTest.java new file mode 100644 index 00000000..190ef0ad --- /dev/null +++ b/src/test/java/com/fowoco/server/settings/application/CompanySettingsAuditSummaryTest.java @@ -0,0 +1,57 @@ +package com.fowoco.server.settings.application; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fowoco.server.approval.domain.EvidenceType; +import com.fowoco.server.settings.domain.ApprovalPolicy; +import com.fowoco.server.settings.domain.AuditVisibility; +import com.fowoco.server.settings.domain.CompanySettings; +import com.fowoco.server.task.domain.TaskType; +import java.time.Instant; +import java.util.EnumMap; +import java.util.EnumSet; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class CompanySettingsAuditSummaryTest { + + @Test + void worstCaseEvidenceDiffRetainsBeforeAndAfterWithinDatabaseLimit() { + Map> allEvidence = new EnumMap<>(TaskType.class); + for (TaskType taskType : TaskType.values()) { + allEvidence.put(taskType, EnumSet.allOf(EvidenceType.class)); + } + CompanySettings before = settings(Map.of()); + CompanySettings after = settings(allEvidence); + + var summaries = CompanySettingsAuditSummary.changedFields(before, after); + + assertThat(summaries).hasSize(TaskType.values().length); + assertThat(summaries).contains( + "evidence_rules.RECONTRACT:[]->[DOCUMENT,RECEIPT,OFFICIAL_RESULT,HR_CONFIRMATION],version:0->0", + "evidence_rules.STAY_PERIOD_EXTENSION:[]->[DOCUMENT,RECEIPT,OFFICIAL_RESULT,HR_CONFIRMATION],version:0->0", + "evidence_rules.WORK_INSTRUCTION:[]->[DOCUMENT,RECEIPT,OFFICIAL_RESULT,HR_CONFIRMATION],version:0->0" + ); + assertThat(summaries).allSatisfy(summary -> + assertThat(summary.length()).isLessThanOrEqualTo(500) + ); + } + + private CompanySettings settings(Map> evidenceRules) { + Instant now = Instant.parse("2026-08-08T00:00:00Z"); + return new CompanySettings( + UUID.fromString("61000000-0000-0000-0000-000000000001"), + ApprovalPolicy.ADMIN_OR_HR, + 72, + evidenceRules, + 365, + 90, + AuditVisibility.ADMIN_ONLY, + now, + now, + 0 + ); + } +} diff --git a/src/test/java/com/fowoco/server/settings/domain/CompanySettingsTest.java b/src/test/java/com/fowoco/server/settings/domain/CompanySettingsTest.java new file mode 100644 index 00000000..3c9b6918 --- /dev/null +++ b/src/test/java/com/fowoco/server/settings/domain/CompanySettingsTest.java @@ -0,0 +1,103 @@ +package com.fowoco.server.settings.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fowoco.server.approval.domain.EvidenceType; +import com.fowoco.server.task.domain.TaskType; +import java.time.Instant; +import java.util.EnumMap; +import java.util.EnumSet; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class CompanySettingsTest { + + private static final UUID COMPANY_ID = + UUID.fromString("10000000-0000-0000-0000-000000000001"); + private static final Instant NOW = Instant.parse("2026-08-08T00:00:00Z"); + + @Test + void defaultsMatchTheFrozenMvpContract() { + CompanySettings settings = CompanySettings.defaults(COMPANY_ID, NOW); + + assertThat(settings.companyId()).isEqualTo(COMPANY_ID); + assertThat(settings.approvalPolicy()).isEqualTo(ApprovalPolicy.ADMIN_OR_HR); + assertThat(settings.linkExpiryHours()).isEqualTo(72L); + assertThat(settings.evidenceRules()).isEmpty(); + assertThat(settings.fileRetentionDays()).isEqualTo(365); + assertThat(settings.aiLogRetentionDays()).isEqualTo(90); + assertThat(settings.auditVisibility()).isEqualTo(AuditVisibility.ADMIN_ONLY); + assertThat(settings.createdAt()).isEqualTo(NOW); + assertThat(settings.updatedAt()).isEqualTo(NOW); + assertThat(settings.version()).isZero(); + } + + @Test + void validatesEveryFrozenNumericRange() { + assertThatThrownBy(() -> settings(0, 365, 90)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("linkExpiryHours"); + assertThatThrownBy(() -> settings(169, 365, 90)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("linkExpiryHours"); + assertThatThrownBy(() -> settings(72, 29, 90)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("fileRetentionDays"); + assertThatThrownBy(() -> settings(72, 3651, 90)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("fileRetentionDays"); + assertThatThrownBy(() -> settings(72, 365, 6)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("aiLogRetentionDays"); + assertThatThrownBy(() -> settings(72, 365, 366)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("aiLogRetentionDays"); + } + + @Test + void evidenceRulesAreDefensivelyImmutable() { + EnumMap> rules = new EnumMap<>(TaskType.class); + EnumSet required = EnumSet.of(EvidenceType.DOCUMENT); + rules.put(TaskType.DOCUMENT_REQUEST, required); + + CompanySettings settings = new CompanySettings( + COMPANY_ID, + ApprovalPolicy.ADMIN_OR_HR, + 72, + rules, + 365, + 90, + AuditVisibility.ADMIN_ONLY, + NOW, + NOW, + 0 + ); + required.add(EvidenceType.RECEIPT); + rules.clear(); + + assertThat(settings.evidenceRules()) + .containsOnlyKeys(TaskType.DOCUMENT_REQUEST); + assertThat(settings.evidenceRules().get(TaskType.DOCUMENT_REQUEST)) + .containsExactly(EvidenceType.DOCUMENT); + assertThatThrownBy(() -> settings.evidenceRules().clear()) + .isInstanceOf(UnsupportedOperationException.class); + } + + private CompanySettings settings(long linkHours, int fileDays, int aiDays) { + return new CompanySettings( + COMPANY_ID, + ApprovalPolicy.ADMIN_OR_HR, + linkHours, + Map.of(), + fileDays, + aiDays, + AuditVisibility.ADMIN_ONLY, + NOW, + NOW, + 0 + ); + } +} diff --git a/src/test/java/com/fowoco/server/settings/infrastructure/persistence/CompanySettingsJsonCodecTest.java b/src/test/java/com/fowoco/server/settings/infrastructure/persistence/CompanySettingsJsonCodecTest.java new file mode 100644 index 00000000..b5be05fb --- /dev/null +++ b/src/test/java/com/fowoco/server/settings/infrastructure/persistence/CompanySettingsJsonCodecTest.java @@ -0,0 +1,42 @@ +package com.fowoco.server.settings.infrastructure.persistence; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fowoco.server.approval.domain.EvidenceType; +import com.fowoco.server.task.domain.TaskType; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; +import tools.jackson.databind.json.JsonMapper; + +class CompanySettingsJsonCodecTest { + + private final CompanySettingsJsonCodec codec = + new CompanySettingsJsonCodec(JsonMapper.builder().build()); + + @Test + void evidenceRulesRoundTripWithStableEnumNames() { + Map> rules = Map.of( + TaskType.WORKER_ONBOARDING, + Set.of(EvidenceType.HR_CONFIRMATION, EvidenceType.DOCUMENT), + TaskType.DOCUMENT_REQUEST, + Set.of(EvidenceType.RECEIPT) + ); + + String json = codec.encodeEvidenceRules(rules); + + assertThat(json).isEqualTo( + "{\"DOCUMENT_REQUEST\":[\"RECEIPT\"]," + + "\"WORKER_ONBOARDING\":[\"DOCUMENT\",\"HR_CONFIRMATION\"]}" + ); + assertThat(codec.decodeEvidenceRules(json)).isEqualTo(rules); + } + + @Test + void rejectsUnknownStoredEnumValues() { + assertThatThrownBy(() -> codec.decodeEvidenceRules("{\"UNKNOWN\":[\"DOCUMENT\"]}")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("invalid"); + } +} diff --git a/src/test/java/com/fowoco/server/task/TaskWorkflowIntegrationTest.java b/src/test/java/com/fowoco/server/task/TaskWorkflowIntegrationTest.java index 44bd38fa..0a9d9cf2 100644 --- a/src/test/java/com/fowoco/server/task/TaskWorkflowIntegrationTest.java +++ b/src/test/java/com/fowoco/server/task/TaskWorkflowIntegrationTest.java @@ -684,6 +684,10 @@ INSERT INTO company ( companyId, name ); + jdbcTemplate.update( + "INSERT INTO company_settings (company_id) VALUES (?)", + companyId + ); } private void insertUser( diff --git a/src/test/java/com/fowoco/server/workerlink/WorkerLinkSecurityIntegrationTest.java b/src/test/java/com/fowoco/server/workerlink/WorkerLinkSecurityIntegrationTest.java index 724799bb..f9cd186d 100644 --- a/src/test/java/com/fowoco/server/workerlink/WorkerLinkSecurityIntegrationTest.java +++ b/src/test/java/com/fowoco/server/workerlink/WorkerLinkSecurityIntegrationTest.java @@ -10,6 +10,8 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.List; import java.util.Map; import java.util.UUID; @@ -82,6 +84,7 @@ void resetState() { jdbcTemplate.update("DELETE FROM task"); jdbcTemplate.update("DELETE FROM worker_document"); jdbcTemplate.update("DELETE FROM worker"); + jdbcTemplate.update("UPDATE company_settings SET link_expiry_hours = 72"); } private void cleanupAll() { @@ -159,6 +162,83 @@ void fullFlow_issueViewUploadRespond_succeeds() throws Exception { assertThat(activitiesResponse.body()).contains("WORKER_LINK_RESPONSE_SUBMITTED"); } + @Test + void issueUsesCompanyDefaultExpiryAndExplicitRequestTakesPrecedence() throws Exception { + String hrToken = accessToken(login(HR_A_EMAIL)); + jdbcTemplate.update( + "UPDATE company_settings SET link_expiry_hours = 24 WHERE company_id = ?", + COMPANY_A + ); + + String defaultWorkerId = registerWorker(hrToken, "회사기본만료시간근로자"); + String defaultTaskId = createApprovedTask(hrToken, defaultWorkerId); + Instant beforeDefaultIssue = Instant.now(); + HttpResponse defaultResponse = postJsonWithIdempotencyKey( + "/api/v1/tasks/" + defaultTaskId + "/worker-link", + """ + {"rotate_existing":false} + """, + hrToken, + "company-default-expiry-key" + ); + Instant afterDefaultIssue = Instant.now(); + + assertThat(defaultResponse.statusCode()) + .as("default expiry response body: %s", defaultResponse.body()) + .isEqualTo(201); + Instant defaultExpiresAt = Instant.parse( + JsonPath.read(defaultResponse.body(), "$.expires_at") + ); + assertThat(defaultExpiresAt) + .isBetween(beforeDefaultIssue.plusSeconds(24L * 60L * 60L) + .truncatedTo(ChronoUnit.MICROS), + afterDefaultIssue.plusSeconds(24L * 60L * 60L)); + + String explicitWorkerId = registerWorker(hrToken, "요청만료시간근로자"); + String explicitTaskId = createApprovedTask(hrToken, explicitWorkerId); + Instant beforeExplicitIssue = Instant.now(); + HttpResponse explicitResponse = postJsonWithIdempotencyKey( + "/api/v1/tasks/" + explicitTaskId + "/worker-link", + """ + {"expires_in_hours":12,"rotate_existing":false} + """, + hrToken, + "explicit-expiry-key" + ); + Instant afterExplicitIssue = Instant.now(); + + assertThat(explicitResponse.statusCode()) + .as("explicit expiry response body: %s", explicitResponse.body()) + .isEqualTo(201); + Instant explicitExpiresAt = Instant.parse( + JsonPath.read(explicitResponse.body(), "$.expires_at") + ); + assertThat(explicitExpiresAt) + .isBetween(beforeExplicitIssue.plusSeconds(12L * 60L * 60L) + .truncatedTo(ChronoUnit.MICROS), + afterExplicitIssue.plusSeconds(12L * 60L * 60L)); + } + + @Test + void issueRejectsExpiryOutsideMvpRange() throws Exception { + String hrToken = accessToken(login(HR_A_EMAIL)); + String workerId = registerWorker(hrToken, "만료시간검증근로자"); + String taskId = createApprovedTask(hrToken, workerId); + + assertThat(postJsonWithIdempotencyKey( + "/api/v1/tasks/" + taskId + "/worker-link", + "{\"expires_in_hours\":0,\"rotate_existing\":false}", + hrToken, + "expiry-too-small-key" + ).statusCode()).isEqualTo(400); + assertThat(postJsonWithIdempotencyKey( + "/api/v1/tasks/" + taskId + "/worker-link", + "{\"expires_in_hours\":169,\"rotate_existing\":false}", + hrToken, + "expiry-too-large-key" + ).statusCode()).isEqualTo(400); + } + @Test void activeWorkerLinkWithoutDraftReturnsContentNotReady() throws Exception { String hrToken = accessToken(login(HR_A_EMAIL)); @@ -361,6 +441,14 @@ void documentsWorkerLinkDeliveryEndpointsInOpenApi() throws Exception { response.body(), "$.paths['/api/v1/worker-links/{workerLinkId}/sent'].post.operationId" )).isEqualTo("markWorkerLinkSent"); + assertThat(JsonPath.read( + response.body(), + "$.components.schemas.WorkerLinkIssueRequest.properties.expires_in_hours.minimum" + ).longValue()).isEqualTo(1L); + assertThat(JsonPath.read( + response.body(), + "$.components.schemas.WorkerLinkIssueRequest.properties.expires_in_hours.maximum" + ).longValue()).isEqualTo(168L); } @Test @@ -584,6 +672,10 @@ INSERT INTO company (company_id, name, status, created_at, updated_at, version) """, companyId, name ); + jdbcTemplate.update( + "INSERT INTO company_settings (company_id) VALUES (?)", + companyId + ); } private void insertUser( diff --git a/src/test/java/com/fowoco/server/workerlink/application/WorkerLinkServiceTest.java b/src/test/java/com/fowoco/server/workerlink/application/WorkerLinkServiceTest.java index 9fbf199a..31c8b036 100644 --- a/src/test/java/com/fowoco/server/workerlink/application/WorkerLinkServiceTest.java +++ b/src/test/java/com/fowoco/server/workerlink/application/WorkerLinkServiceTest.java @@ -1,21 +1,30 @@ package com.fowoco.server.workerlink.application; +import static org.assertj.core.api.Assertions.assertThat; 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.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; 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.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.settings.application.port.CompanySettingsRepository; +import com.fowoco.server.settings.domain.CompanySettings; import com.fowoco.server.task.application.port.TaskRepository; +import com.fowoco.server.task.domain.Task; 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.time.Instant; +import java.time.ZoneOffset; import java.util.Optional; import java.util.Set; import java.util.UUID; @@ -33,11 +42,20 @@ class WorkerLinkServiceTest { private static final UUID ACTOR_ID = UUID.fromString( "30000000-0000-0000-0000-000000000003" ); + private static final UUID WORKER_ID = UUID.fromString( + "40000000-0000-0000-0000-000000000004" + ); + private static final UUID WORKER_LINK_ID = UUID.fromString( + "50000000-0000-0000-0000-000000000005" + ); + private static final Instant NOW = Instant.parse("2026-08-08T12:00:00Z"); @Test void bindsActorTenantBeforeFirstRepositoryAccess() { TaskRepository taskRepository = mock(TaskRepository.class); ApprovalRequestRepository approvalRepository = mock(ApprovalRequestRepository.class); + CompanySettingsRepository companySettingsRepository = + mock(CompanySettingsRepository.class); WorkerLinkRepository workerLinkRepository = mock(WorkerLinkRepository.class); TenantDatabaseContext tenantDatabaseContext = mock(TenantDatabaseContext.class); when(taskRepository.findByIdAndCompanyId(TASK_ID, COMPANY_ID)) @@ -46,6 +64,7 @@ void bindsActorTenantBeforeFirstRepositoryAccess() { WorkerLinkService service = new WorkerLinkService( taskRepository, approvalRepository, + companySettingsRepository, workerLinkRepository, mock(WorkerLinkGenerator.class), mock(WorkerLinkHasher.class), @@ -72,4 +91,103 @@ void bindsActorTenantBeforeFirstRepositoryAccess() { order.verify(tenantDatabaseContext).setCompanyIdForCurrentTransaction(COMPANY_ID); order.verify(taskRepository).findByIdAndCompanyId(TASK_ID, COMPANY_ID); } + + @Test + void usesCompanySettingWhenRequestExpiryIsOmitted() { + ServiceFixture fixture = validFixture(); + when(fixture.companySettingsRepository().findByCompanyId(COMPANY_ID)) + .thenReturn(Optional.of(companySettings(24L))); + + WorkerLinkIssueResult result = fixture.service().issue(command(null), actor()); + + assertThat(result.expiresAt()).isEqualTo(NOW.plusSeconds(24L * 60L * 60L)); + verify(fixture.companySettingsRepository()).findByCompanyId(COMPANY_ID); + } + + @Test + void explicitRequestExpiryTakesPrecedenceWithoutReadingCompanySetting() { + ServiceFixture fixture = validFixture(); + + WorkerLinkIssueResult result = fixture.service().issue(command(12L), actor()); + + assertThat(result.expiresAt()).isEqualTo(NOW.plusSeconds(12L * 60L * 60L)); + verifyNoInteractions(fixture.companySettingsRepository()); + } + + private ServiceFixture validFixture() { + TaskRepository taskRepository = mock(TaskRepository.class); + ApprovalRequestRepository approvalRepository = mock(ApprovalRequestRepository.class); + CompanySettingsRepository companySettingsRepository = + mock(CompanySettingsRepository.class); + WorkerLinkRepository workerLinkRepository = mock(WorkerLinkRepository.class); + WorkerLinkGenerator workerLinkGenerator = mock(WorkerLinkGenerator.class); + WorkerLinkHasher workerLinkHasher = mock(WorkerLinkHasher.class); + TenantDatabaseContext tenantDatabaseContext = mock(TenantDatabaseContext.class); + UuidGenerator uuidGenerator = mock(UuidGenerator.class); + Task task = mock(Task.class); + ApprovalRequest approval = mock(ApprovalRequest.class); + + when(task.workerId()).thenReturn(WORKER_ID); + when(task.contentRevision()).thenReturn(2L); + when(task.criticalFingerprint()).thenReturn("approved-fingerprint"); + when(approval.isValidFor(2L, "approved-fingerprint")).thenReturn(true); + when(taskRepository.findByIdAndCompanyId(TASK_ID, COMPANY_ID)) + .thenReturn(Optional.of(task)); + when(approvalRepository.findLatestApprovedByTaskIdAndCompanyId(TASK_ID, COMPANY_ID)) + .thenReturn(Optional.of(approval)); + when(workerLinkHasher.hash("worker-link-issue-1")).thenReturn("idempotency-hash"); + when(workerLinkRepository.findByTaskIdAndIdempotencyKey(TASK_ID, "idempotency-hash")) + .thenReturn(Optional.empty()); + when(workerLinkRepository.findActiveByTaskIdAndCompanyId(TASK_ID, COMPANY_ID)) + .thenReturn(Optional.empty()); + when(workerLinkGenerator.generate()).thenReturn( + new WorkerLinkGenerator.GeneratedWorkerLinkToken("raw-token", "token-hash") + ); + when(uuidGenerator.generate()).thenReturn(WORKER_LINK_ID); + + WorkerLinkService service = new WorkerLinkService( + taskRepository, + approvalRepository, + companySettingsRepository, + workerLinkRepository, + workerLinkGenerator, + workerLinkHasher, + tenantDatabaseContext, + uuidGenerator, + Clock.fixed(NOW, ZoneOffset.UTC) + ); + return new ServiceFixture(service, companySettingsRepository); + } + + private CompanySettings companySettings(long linkExpiryHours) { + CompanySettings defaults = CompanySettings.defaults(COMPANY_ID, NOW); + return defaults.update( + defaults.approvalPolicy(), + linkExpiryHours, + defaults.evidenceRules(), + defaults.fileRetentionDays(), + defaults.aiLogRetentionDays(), + defaults.auditVisibility(), + NOW + ); + } + + private WorkerLinkIssueCommand command(Long expiresInHours) { + return new WorkerLinkIssueCommand( + TASK_ID, + expiresInHours, + false, + "worker-link-issue-1" + ); + } + + private ActorContext actor() { + return new ActorContext(ACTOR_ID, COMPANY_ID, Set.of(UserRole.HR)); + } + + private record ServiceFixture( + WorkerLinkService service, + CompanySettingsRepository companySettingsRepository + ) { + } }