From 5f6b2c5eda180e90c3b10492c30e17266507ca41 Mon Sep 17 00:00:00 2001 From: redjungi Date: Thu, 3 Sep 2026 20:55:07 +0900 Subject: [PATCH 1/3] =?UTF-8?q?refactor:=20=EB=8C=80=EC=83=81=EB=8B=B9=20?= =?UTF-8?q?=ED=99=9C=EC=84=B1=20=EA=B3=B5=EC=9C=A0=20=EB=A7=81=ED=81=AC=20?= =?UTF-8?q?=EC=9C=A0=EB=8B=88=ED=81=AC=20=EC=A0=9C=EC=95=BD=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 대상 구성은 조인 테이블 둘에 흩어져 있어 유니크 제약을 직접 걸 수 없다. 정렬된 식별자 목록의 해시를 비정규화해 shares 한 테이블에서 비교한다. --- ...enforce_single_active_share_per_target.sql | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 src/main/resources/db/migration/V31__enforce_single_active_share_per_target.sql diff --git a/src/main/resources/db/migration/V31__enforce_single_active_share_per_target.sql b/src/main/resources/db/migration/V31__enforce_single_active_share_per_target.sql new file mode 100644 index 0000000..7e25f14 --- /dev/null +++ b/src/main/resources/db/migration/V31__enforce_single_active_share_per_target.sql @@ -0,0 +1,52 @@ +-- "한 사용자, 한 대상 구성, 활성 링크 1개" 를 DB 가 강제한다. +-- +-- 지금까지는 공유 버튼을 누를 때마다 새 링크가 생기고 이전 링크가 그대로 살아 있었다. 살아 +-- 있는 공개 URL 의 개수에 상한이 없어, 시술기록이 몇 건이든 활성 링크는 얼마든지 늘어난다. +-- +-- 대상 구성은 share_records·share_saved_styles 두 조인 테이블에 흩어져 있어 유니크 제약을 +-- 직접 걸 수 없다. 그래서 정렬된 식별자 목록의 SHA-256 을 target_hash 로 비정규화해 shares +-- 한 테이블 안에서 비교한다. +-- +-- 부분 인덱스의 조건은 status = 'ACTIVE' 뿐이다. 만료는 expires_at 비교라 인덱스 조건에 +-- now() 를 쓸 수 없다(불변 표현식이 아니다). 그래서 만료됐지만 상태가 ACTIVE 인 행도 인덱스 +-- 대상이다 — 애플리케이션이 발급 직전에 같은 대상의 ACTIVE 행을 만료 여부와 무관하게 모두 +-- 폐기하므로 충돌하지 않는다. +ALTER TABLE shares ADD COLUMN target_hash VARCHAR(64); + +-- 백필. 정렬 키를 uuid 가 아니라 text 로 두는 이유는 애플리케이션이 UUID 문자열을 정렬해 +-- 같은 문자열을 만들기 때문이다. 두 정렬 기준이 어긋나면 같은 대상이 다른 해시를 갖는다. +UPDATE shares s +SET target_hash = encode(sha256(convert_to(t.payload, 'UTF8')), 'hex') +FROM ( + SELECT sh.share_id, + coalesce((SELECT string_agg(r.record_id::text, ',' ORDER BY r.record_id::text) + FROM share_records r WHERE r.share_id = sh.share_id), '') + || '|' + || coalesce((SELECT string_agg(ss.saved_style_id::text, ',' ORDER BY ss.saved_style_id::text) + FROM share_saved_styles ss WHERE ss.share_id = sh.share_id), '') AS payload + FROM shares sh +) t +WHERE s.share_id = t.share_id; + +ALTER TABLE shares ALTER COLUMN target_hash SET NOT NULL; + +-- 기존 중복 정리. 같은 대상을 가리키는 활성 링크 중 가장 최근 것만 남긴다. 사용자가 마지막에 +-- 발급한 링크가 지금 쓰고 있는 링크이고, 그 이전 것들은 이미 대체된 것으로 본다. +UPDATE shares +SET status = 'REVOKED', + revoked_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP +WHERE share_id IN ( + SELECT share_id FROM ( + SELECT share_id, + row_number() OVER (PARTITION BY user_id, target_hash + ORDER BY created_at DESC, share_id DESC) AS recency + FROM shares + WHERE status = 'ACTIVE' + ) ranked + WHERE ranked.recency > 1 +); + +CREATE UNIQUE INDEX uq_shares_active_target + ON shares(user_id, target_hash) + WHERE status = 'ACTIVE'; From c1b37bb58a6e1e60035a8d93eb4926b808f44f22 Mon Sep 17 00:00:00 2001 From: redjungi Date: Thu, 3 Sep 2026 20:55:07 +0900 Subject: [PATCH 2/3] =?UTF-8?q?refactor:=20=EA=B0=99=EC=9D=80=20=EB=8C=80?= =?UTF-8?q?=EC=83=81=EC=9D=84=20=EB=8B=A4=EC=8B=9C=20=EA=B3=B5=EC=9C=A0?= =?UTF-8?q?=ED=95=98=EB=A9=B4=20=EC=9D=B4=EC=A0=84=20=EB=A7=81=ED=81=AC?= =?UTF-8?q?=EB=A5=BC=20=ED=8F=90=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 발급할 때마다 링크가 쌓여 살아있는 공개 URL 수에 상한이 없었다. 토큰 원문을 저장하지 않아 기존 링크의 URL 을 다시 내려줄 수 없으므로 재사용이 아니라 교체다. --- .../out/persistence/sharing/ShareEntity.java | 10 +++++++++- .../persistence/sharing/ShareJpaRepository.java | 17 +++++++++++++++++ .../sharing/SharingPersistenceAdapter.java | 17 ++++++++++++++++- .../sharing/service/ShareService.java | 4 ++++ .../com/heddy/domain/sharing/model/Share.java | 16 ++++++++++++++++ .../sharing/port/out/ShareRepositoryPort.java | 10 ++++++++++ 6 files changed, 72 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/heddy/adapter/out/persistence/sharing/ShareEntity.java b/src/main/java/com/heddy/adapter/out/persistence/sharing/ShareEntity.java index ee276e1..e93e52a 100644 --- a/src/main/java/com/heddy/adapter/out/persistence/sharing/ShareEntity.java +++ b/src/main/java/com/heddy/adapter/out/persistence/sharing/ShareEntity.java @@ -42,6 +42,13 @@ class ShareEntity extends BaseEntity { @Column(name = "token_hash", nullable = false, unique = true, updatable = false, length = 64) private String tokenHash; + /** + * 대상 구성의 SHA-256. 두 조인 테이블에 흩어진 대상을 shares 한 테이블에서 비교하려고 + * 비정규화한 인덱스 키다(V31). 대상은 발급 후 바뀌지 않으므로 갱신하지 않는다. + */ + @Column(name = "target_hash", nullable = false, updatable = false, length = 64) + private String targetHash; + @Column(name = "status", nullable = false) private String status; @@ -69,7 +76,8 @@ class ShareEntity extends BaseEntity { protected ShareEntity() { } - ShareEntity(Share share) { + ShareEntity(Share share, String targetHash) { + this.targetHash = targetHash; shareId = share.shareId(); userId = share.userId(); tokenHash = share.tokenHash(); diff --git a/src/main/java/com/heddy/adapter/out/persistence/sharing/ShareJpaRepository.java b/src/main/java/com/heddy/adapter/out/persistence/sharing/ShareJpaRepository.java index 18d3fbd..a09e019 100644 --- a/src/main/java/com/heddy/adapter/out/persistence/sharing/ShareJpaRepository.java +++ b/src/main/java/com/heddy/adapter/out/persistence/sharing/ShareJpaRepository.java @@ -26,6 +26,23 @@ interface ShareJpaRepository extends JpaRepository { nativeQuery = true) void deleteSavedStyleLinks(@Param("savedStyleId") UUID savedStyleId); + /** + * 같은 대상의 활성 공유를 한 번에 폐기한다. 만료 여부는 보지 않는다 — 부분 유니크 인덱스가 + * 상태만 보므로(V31) 만료된 ACTIVE 행을 남기면 다음 발급이 인덱스에 걸린다. + */ + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + UPDATE ShareEntity share + SET share.status = 'REVOKED', share.revokedAt = :revokedAt + WHERE share.userId = :userId + AND share.targetHash = :targetHash + AND share.status = 'ACTIVE' + """) + int revokeActiveWithSameTarget( + @Param("userId") UUID userId, + @Param("targetHash") String targetHash, + @Param("revokedAt") Instant revokedAt); + Optional findByTokenHash(String tokenHash); Page findByUserId(UUID userId, Pageable pageable); diff --git a/src/main/java/com/heddy/adapter/out/persistence/sharing/SharingPersistenceAdapter.java b/src/main/java/com/heddy/adapter/out/persistence/sharing/SharingPersistenceAdapter.java index 45a8c1e..f4b09a1 100644 --- a/src/main/java/com/heddy/adapter/out/persistence/sharing/SharingPersistenceAdapter.java +++ b/src/main/java/com/heddy/adapter/out/persistence/sharing/SharingPersistenceAdapter.java @@ -5,6 +5,7 @@ import com.heddy.domain.sharing.model.ShareStatus; import com.heddy.domain.sharing.port.out.ShareRepositoryPort; import com.heddy.domain.sharing.port.out.SharedRecordLookupPort; +import com.heddy.domain.account.port.out.TokenHasherPort; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -24,10 +25,24 @@ public class SharingPersistenceAdapter implements ShareRepositoryPort, SharedRec private static final int MAX_PAGE_SIZE = 100; private final ShareJpaRepository shareRepository; + /** 토큰과 같은 SHA-256 을 대상 구성에도 쓴다. 해시 계산이 한 곳이어야 값이 갈리지 않는다. */ + private final TokenHasherPort tokenHasherPort; @Override public Share insert(Share share) { - return shareRepository.saveAndFlush(new ShareEntity(share)).toDomain(); + return shareRepository + .saveAndFlush(new ShareEntity(share, targetHash(share.targetKey()))) + .toDomain(); + } + + @Override + public int revokeActiveWithSameTarget(UUID userId, String targetKey, Instant revokedAt) { + return shareRepository.revokeActiveWithSameTarget( + userId, targetHash(targetKey), revokedAt); + } + + private String targetHash(String targetKey) { + return tokenHasherPort.hash(targetKey); } @Override diff --git a/src/main/java/com/heddy/application/sharing/service/ShareService.java b/src/main/java/com/heddy/application/sharing/service/ShareService.java index e206b43..fe1cf1b 100644 --- a/src/main/java/com/heddy/application/sharing/service/ShareService.java +++ b/src/main/java/com/heddy/application/sharing/service/ShareService.java @@ -72,6 +72,10 @@ public CreateShareUseCase.Result create(CreateShareUseCase.Command command) { command.savedStyleIds(), command.fields(), command.expiresInDays(), Instant.now()); requireOwnedRecords(command.userId(), command.recordIds()); requireOwnedSavedStyles(command.userId(), command.savedStyleIds()); + // 같은 대상을 다시 공유하는 것은 "이전 링크는 그만 쓰겠다" 는 뜻으로 본다. 토큰 원문을 + // 저장하지 않아 기존 링크의 URL 을 다시 내려줄 수 없으므로, 재사용이 아니라 교체다. + shareRepositoryPort.revokeActiveWithSameTarget( + command.userId(), share.targetKey(), Instant.now()); Share saved = shareRepositoryPort.insert(share); return new CreateShareUseCase.Result(saved, publicBaseUrl + "/" + rawToken); } diff --git a/src/main/java/com/heddy/domain/sharing/model/Share.java b/src/main/java/com/heddy/domain/sharing/model/Share.java index 1d09dde..0c87916 100644 --- a/src/main/java/com/heddy/domain/sharing/model/Share.java +++ b/src/main/java/com/heddy/domain/sharing/model/Share.java @@ -6,6 +6,7 @@ import java.time.Instant; import java.util.Objects; import java.util.Set; +import java.util.stream.Collectors; import java.util.UUID; /** @@ -83,6 +84,21 @@ public static Share create( now.plusSeconds((long) days * 86_400), null, recordIds, fields, savedStyleIds, now); } + /** + * 대상 구성의 정규형. 같은 기록·후보를 고른 두 공유는 고르는 순서와 무관하게 같은 문자열을 + * 낸다. 해싱은 이 문자열을 받는 쪽의 일이고, 여기서는 "무엇이 같은 대상인가" 만 정한다. + * + *

정렬은 UUID 문자열 기준이다. V31 의 백필도 같은 기준을 쓴다 — 두 정렬이 어긋나면 + * 같은 대상이 서로 다른 해시를 갖고 중복 제거가 통째로 무력해진다. + */ + public String targetKey() { + return join(recordIds) + "|" + join(savedStyleIds); + } + + private static String join(Set ids) { + return ids.stream().map(UUID::toString).sorted().collect(Collectors.joining(",")); + } + /** 이미 읽어 온 행을 도메인으로 되돌릴 때 쓰는 재구성용 팩터리다. 불변식을 다시 통과한다. */ public static Share reconstitute( UUID shareId, diff --git a/src/main/java/com/heddy/domain/sharing/port/out/ShareRepositoryPort.java b/src/main/java/com/heddy/domain/sharing/port/out/ShareRepositoryPort.java index b92dc2c..6b318d6 100644 --- a/src/main/java/com/heddy/domain/sharing/port/out/ShareRepositoryPort.java +++ b/src/main/java/com/heddy/domain/sharing/port/out/ShareRepositoryPort.java @@ -22,6 +22,16 @@ public interface ShareRepositoryPort { */ Optional findByIdAndUserId(UUID shareId, UUID userId); + /** + * 같은 대상 구성을 가리키는 이 소유자의 활성 공유를 모두 폐기한다. + * + *

만료 여부를 보지 않는 것이 중요하다. 부분 유니크 인덱스의 조건이 상태뿐이라(V31) + * 만료됐지만 상태가 ACTIVE 인 행을 남겨 두면 다음 발급이 인덱스에 걸린다. + * + * @return 폐기된 공유 수 + */ + int revokeActiveWithSameTarget(UUID userId, String targetKey, Instant revokedAt); + /** 토큰 해시 대조로 조회한다. 공개 조회(#51)의 유일한 진입 경로다. */ Optional findByTokenHash(String tokenHash); From cf7cc930108f324277835df066879e55fad12cbe Mon Sep 17 00:00:00 2001 From: redjungi Date: Thu, 3 Sep 2026 20:55:07 +0900 Subject: [PATCH 3/3] =?UTF-8?q?test:=20=EB=8C=80=EC=83=81=EB=8B=B9=20?= =?UTF-8?q?=ED=99=9C=EC=84=B1=20=EB=A7=81=ED=81=AC=201=EA=B0=9C=20?= =?UTF-8?q?=EC=A0=9C=EC=95=BD=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PublicShareApiIntegrationTest.java | 4 +- .../web/sharing/ShareApiIntegrationTest.java | 117 ++++++++++++++++++ .../style/SavedStyleApiIntegrationTest.java | 6 +- .../summary/SummaryApiIntegrationTest.java | 5 +- .../TreatmentRecordApiIntegrationTest.java | 5 +- ...ringPersistenceAdapterIntegrationTest.java | 61 ++++++++- .../MySummaryJdbcAdapterIntegrationTest.java | 18 +-- .../AccountDeletionWorkerIntegrationTest.java | 4 +- .../sharing/service/ShareServiceTest.java | 26 ++++ .../heddy/domain/sharing/model/ShareTest.java | 28 +++++ 10 files changed, 254 insertions(+), 20 deletions(-) diff --git a/src/test/java/com/heddy/adapter/in/web/sharing/PublicShareApiIntegrationTest.java b/src/test/java/com/heddy/adapter/in/web/sharing/PublicShareApiIntegrationTest.java index 180a642..34d82af 100644 --- a/src/test/java/com/heddy/adapter/in/web/sharing/PublicShareApiIntegrationTest.java +++ b/src/test/java/com/heddy/adapter/in/web/sharing/PublicShareApiIntegrationTest.java @@ -272,8 +272,8 @@ INSERT INTO treatment_records ( UUID shareId = UUID.randomUUID(); jdbcTemplate.update(""" INSERT INTO shares ( - share_id, user_id, token_hash, status, expires_at - ) VALUES (?, ?, ?, ?, ?) + share_id, user_id, token_hash, target_hash, status, expires_at + ) VALUES (?, ?, ?, md5(random()::text), ?, ?) """, shareId, USER_ID, sha256Quiet(token), status, Timestamp.from(expiresAt)); jdbcTemplate.update(""" diff --git a/src/test/java/com/heddy/adapter/in/web/sharing/ShareApiIntegrationTest.java b/src/test/java/com/heddy/adapter/in/web/sharing/ShareApiIntegrationTest.java index 31a5cd0..1d25413 100644 --- a/src/test/java/com/heddy/adapter/in/web/sharing/ShareApiIntegrationTest.java +++ b/src/test/java/com/heddy/adapter/in/web/sharing/ShareApiIntegrationTest.java @@ -431,6 +431,123 @@ void answers404ForUnknownShareOnDetailPatchAndDelete() throws Exception { .andExpect(status().isNotFound()); } + // ------------------------------------------------------- 대상당 활성 링크 1개 + + /** + * 같은 기록을 다시 공유하면 이전 링크는 닫힌다. 발급할 때마다 링크가 쌓이면 사용자가 + * 닫은 줄 아는 URL 이 계속 열려 있게 되고, 살아있는 링크 수에 상한이 없어진다. + */ + @Test + void revokesThePreviousLinkWhenTheSameTargetIsSharedAgain() throws Exception { + UUID recordId = insertRecord(USER_ID); + String firstToken = tokenOf(createShare(createBody(recordId, 7))); + + createShare(createBody(recordId, 7)); + + mockMvc.perform(get("/public/shares/" + firstToken)) + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.error.code").value("SHARE_REVOKED")); + Integer active = jdbcTemplate.queryForObject( + "SELECT count(*) FROM shares WHERE user_id = ? AND status = 'ACTIVE'", + Integer.class, USER_ID); + assertThat(active).isEqualTo(1); + } + + /** 대상이 다르면 공존한다. 막는 것은 같은 대상의 중복이지 링크 발급 자체가 아니다. */ + @Test + void keepsLinksThatPointAtDifferentTargets() throws Exception { + UUID first = insertRecord(USER_ID); + UUID second = insertRecord(USER_ID); + + createShare(createBody(first, 7)); + createShare(createBody(second, 7)); + + Integer active = jdbcTemplate.queryForObject( + "SELECT count(*) FROM shares WHERE user_id = ? AND status = 'ACTIVE'", + Integer.class, USER_ID); + assertThat(active).isEqualTo(2); + } + + /** + * 기록 순서를 바꿔 보내도 같은 대상이다. 정규형이 순서를 지우지 못하면 같은 조합으로 + * 링크가 무한히 쌓인다. + */ + @Test + void treatsTheSameRecordsInADifferentOrderAsOneTarget() throws Exception { + UUID first = insertRecord(USER_ID); + UUID second = insertRecord(USER_ID); + String body = """ + {"record_ids":["%s","%s"],"fields":["PHOTOS"],"expires_in_days":7} + """; + + createShare(body.formatted(first, second)); + createShare(body.formatted(second, first)); + + Integer active = jdbcTemplate.queryForObject( + "SELECT count(*) FROM shares WHERE user_id = ? AND status = 'ACTIVE'", + Integer.class, USER_ID); + assertThat(active).isEqualTo(1); + } + + /** + * 만료됐지만 상태가 ACTIVE 인 행이 남아 있어도 다음 발급이 막히지 않아야 한다. 부분 유니크 + * 인덱스는 만료를 보지 못하므로(now() 는 불변 표현식이 아니다) 폐기가 만료 여부와 무관하게 + * 이루어져야 한다. + */ + @Test + void reissuesOverAnExpiredButStillActiveRow() throws Exception { + UUID recordId = insertRecord(USER_ID); + createShare(createBody(recordId, 7)); + jdbcTemplate.update( + "UPDATE shares SET expires_at = now() - interval '1 day' WHERE user_id = ?", + USER_ID); + + createShare(createBody(recordId, 7)); + + Integer active = jdbcTemplate.queryForObject( + "SELECT count(*) FROM shares WHERE user_id = ? AND status = 'ACTIVE'", + Integer.class, USER_ID); + assertThat(active).isEqualTo(1); + } + + /** 남의 공유는 건드리지 않는다. 대상이 같아도 소유자가 다르면 별개의 링크다. */ + @Test + void leavesAnotherOwnersLinkAloneEvenOnTheSameRecord() throws Exception { + UUID recordId = insertRecord(USER_ID); + UUID otherRecord = insertRecord(OTHER_USER_ID); + createShare(createBody(recordId, 7)); + UUID otherShareId = UUID.randomUUID(); + jdbcTemplate.update(""" + INSERT INTO shares ( + share_id, user_id, token_hash, target_hash, status, expires_at + ) VALUES (?, ?, ?, md5(random()::text), 'ACTIVE', now() + interval '1 day') + """, otherShareId, OTHER_USER_ID, UUID.randomUUID().toString().replace("-", "")); + jdbcTemplate.update( + "INSERT INTO share_records (share_id, record_id) VALUES (?, ?)", + otherShareId, otherRecord); + + createShare(createBody(recordId, 7)); + + String otherStatus = jdbcTemplate.queryForObject( + "SELECT status FROM shares WHERE share_id = ?", String.class, otherShareId); + assertThat(otherStatus).isEqualTo("ACTIVE"); + } + + private String createShare(String body) throws Exception { + return mockMvc.perform(post("/shares") + .with(authentication(userAuthentication(USER_ID))) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isCreated()) + .andReturn().getResponse().getContentAsString(); + } + + private String tokenOf(String createResponse) throws Exception { + String shareUrl = new ObjectMapper().readTree(createResponse) + .path("data").path("share_url").asText(); + return shareUrl.substring(shareUrl.lastIndexOf('/') + 1); + } + // ------------------------------------------------------------------ 헬퍼 private String createShare(UUID recordId) throws Exception { diff --git a/src/test/java/com/heddy/adapter/in/web/style/SavedStyleApiIntegrationTest.java b/src/test/java/com/heddy/adapter/in/web/style/SavedStyleApiIntegrationTest.java index 4c55920..955415e 100644 --- a/src/test/java/com/heddy/adapter/in/web/style/SavedStyleApiIntegrationTest.java +++ b/src/test/java/com/heddy/adapter/in/web/style/SavedStyleApiIntegrationTest.java @@ -310,8 +310,10 @@ private UUID saveCandidate(UUID userId, UUID hairstyleId, UUID colorId) throws E private UUID insertShareCarrying(UUID savedStyleId) { UUID shareId = UUID.randomUUID(); jdbcTemplate.update(""" - INSERT INTO shares (share_id, user_id, token_hash, status, expires_at, created_at) - VALUES (?, ?, ?, 'ACTIVE', now() + interval '1 day', now()) + INSERT INTO shares ( + share_id, user_id, token_hash, target_hash, status, expires_at, created_at + ) VALUES (?, ?, ?, md5(random()::text), 'ACTIVE', + now() + interval '1 day', now()) """, shareId, USER_ID, "hash-" + shareId); jdbcTemplate.update( "INSERT INTO share_saved_styles (share_id, saved_style_id) VALUES (?, ?)", diff --git a/src/test/java/com/heddy/adapter/in/web/summary/SummaryApiIntegrationTest.java b/src/test/java/com/heddy/adapter/in/web/summary/SummaryApiIntegrationTest.java index 39a029b..51202bb 100644 --- a/src/test/java/com/heddy/adapter/in/web/summary/SummaryApiIntegrationTest.java +++ b/src/test/java/com/heddy/adapter/in/web/summary/SummaryApiIntegrationTest.java @@ -98,8 +98,9 @@ INSERT INTO treatment_records (record_id, user_id, service_types, performed_at) private void insertActiveShare(UUID recordId) { UUID shareId = UUID.randomUUID(); jdbcTemplate.update(""" - INSERT INTO shares (share_id, user_id, token_hash, status, expires_at) - VALUES (?, ?, ?, 'ACTIVE', now() + interval '7 days') + INSERT INTO shares ( + share_id, user_id, token_hash, target_hash, status, expires_at + ) VALUES (?, ?, ?, md5(random()::text), 'ACTIVE', now() + interval '7 days') """, shareId, USER_ID, UUID.randomUUID().toString().replace("-", "")); jdbcTemplate.update( "INSERT INTO share_records (share_id, record_id) VALUES (?, ?)", diff --git a/src/test/java/com/heddy/adapter/in/web/treatment/TreatmentRecordApiIntegrationTest.java b/src/test/java/com/heddy/adapter/in/web/treatment/TreatmentRecordApiIntegrationTest.java index 5950fcc..a644e11 100644 --- a/src/test/java/com/heddy/adapter/in/web/treatment/TreatmentRecordApiIntegrationTest.java +++ b/src/test/java/com/heddy/adapter/in/web/treatment/TreatmentRecordApiIntegrationTest.java @@ -1025,8 +1025,9 @@ INSERT INTO analysis_jobs ( private void insertShare(UUID ownerId, UUID recordId, String status, Instant expiresAt) { UUID shareId = UUID.randomUUID(); jdbcTemplate.update(""" - INSERT INTO shares (share_id, user_id, token_hash, status, expires_at, revoked_at) - VALUES (?, ?, ?, ?, ?, ?) + INSERT INTO shares ( + share_id, user_id, token_hash, target_hash, status, expires_at, revoked_at + ) VALUES (?, ?, ?, md5(random()::text), ?, ?, ?) """, shareId, ownerId, UUID.randomUUID().toString().replace("-", ""), status, Timestamp.from(expiresAt), "REVOKED".equals(status) ? Timestamp.from(Instant.now()) : null); diff --git a/src/test/java/com/heddy/adapter/out/persistence/sharing/SharingPersistenceAdapterIntegrationTest.java b/src/test/java/com/heddy/adapter/out/persistence/sharing/SharingPersistenceAdapterIntegrationTest.java index 9ddab08..de5e5f0 100644 --- a/src/test/java/com/heddy/adapter/out/persistence/sharing/SharingPersistenceAdapterIntegrationTest.java +++ b/src/test/java/com/heddy/adapter/out/persistence/sharing/SharingPersistenceAdapterIntegrationTest.java @@ -117,9 +117,9 @@ void answersWithoutQueryingWhenThereAreNoRecords() { */ @Test void excludesExpiredSharesFromTheActiveFilter() { - UUID recordId = insertRecord(OWNER_ID); - insertShare(OWNER_ID, recordId, ShareStatus.ACTIVE, NOW.minusSeconds(1)); - insertShare(OWNER_ID, recordId, ShareStatus.ACTIVE, NOW.plusSeconds(60)); + // 대상이 서로 달라야 한다 — 같은 대상의 활성 링크는 하나뿐이다(V31). + insertShare(OWNER_ID, insertRecord(OWNER_ID), ShareStatus.ACTIVE, NOW.minusSeconds(1)); + insertShare(OWNER_ID, insertRecord(OWNER_ID), ShareStatus.ACTIVE, NOW.plusSeconds(60)); SharePage page = adapter.findPage(OWNER_ID, ShareStatus.ACTIVE, 0, 20, NOW); @@ -152,6 +152,61 @@ void keepsExpiredSharesInTheRevokedFilter() { assertThat(adapter.findPage(OWNER_ID, ShareStatus.REVOKED, 0, 20, NOW).items()).hasSize(1); } + // ------------------------------------------------------------- 대상 해시 + + /** + * 애플리케이션이 만드는 해시와 V31 백필이 만드는 해시가 같아야 한다. 어긋나면 기존 행과 + * 새 행이 같은 대상인데도 다른 해시를 갖게 되고, 중복 제거가 조용히 무력해진다. + * + *

여기서 쓰는 SQL 은 V31 의 백필 식과 같은 모양이다. + */ + @Test + void computesTheSameTargetHashAsTheBackfill() { + UUID first = insertRecord(OWNER_ID); + UUID second = insertRecord(OWNER_ID); + adapter.insert(Share.reconstitute( + UUID.randomUUID(), OWNER_ID, UUID.randomUUID().toString().replace("-", ""), + ShareStatus.ACTIVE, NOW.plusSeconds(60), null, + Set.of(first, second), Set.of(ShareFieldType.PHOTOS), Set.of(), NOW)); + + Boolean matches = jdbcTemplate.queryForObject(""" + SELECT s.target_hash = encode(sha256(convert_to(t.payload, 'UTF8')), 'hex') + FROM shares s + JOIN (SELECT sh.share_id, + coalesce((SELECT string_agg(r.record_id::text, ',' + ORDER BY r.record_id::text) + FROM share_records r WHERE r.share_id = sh.share_id), '') + || '|' + || coalesce((SELECT string_agg(ss.saved_style_id::text, ',' + ORDER BY ss.saved_style_id::text) + FROM share_saved_styles ss + WHERE ss.share_id = sh.share_id), '') AS payload + FROM shares sh) t ON t.share_id = s.share_id + WHERE s.user_id = ? + """, Boolean.class, OWNER_ID); + + assertThat(matches).isTrue(); + } + + /** + * 같은 대상의 활성 링크만 닫는다. 만료된 ACTIVE 행도 닫아야 한다 — 부분 유니크 인덱스가 + * 만료를 보지 못하므로 남겨 두면 다음 발급이 인덱스에 걸린다. + */ + @Test + void revokesEveryActiveLinkOnTheSameTargetRegardlessOfExpiry() { + UUID recordId = insertRecord(OWNER_ID); + UUID untouched = insertRecord(OWNER_ID); + insertShare(OWNER_ID, recordId, ShareStatus.ACTIVE, NOW.minusSeconds(1)); + insertShare(OWNER_ID, untouched, ShareStatus.ACTIVE, NOW.plusSeconds(60)); + + int revoked = adapter.revokeActiveWithSameTarget(OWNER_ID, recordId + "|", NOW); + + assertThat(revoked).isEqualTo(1); + assertThat(adapter.findPage(OWNER_ID, ShareStatus.ACTIVE, 0, 20, NOW).items()) + .singleElement() + .satisfies(share -> assertThat(share.recordIds()).containsExactly(untouched)); + } + // ------------------------------------------------------------------ 헬퍼 private void insertUser(UUID userId, String email) { diff --git a/src/test/java/com/heddy/adapter/out/persistence/summary/MySummaryJdbcAdapterIntegrationTest.java b/src/test/java/com/heddy/adapter/out/persistence/summary/MySummaryJdbcAdapterIntegrationTest.java index 9a93dd0..519a4ee 100644 --- a/src/test/java/com/heddy/adapter/out/persistence/summary/MySummaryJdbcAdapterIntegrationTest.java +++ b/src/test/java/com/heddy/adapter/out/persistence/summary/MySummaryJdbcAdapterIntegrationTest.java @@ -42,13 +42,16 @@ void answersZeroForEveryCountWhenThereIsNothing() { .isEqualTo(new MySummary(0, 0, 0, 0)); } - /** 같은 기록으로 링크를 여러 번 만들어도 기록은 하나다. 여기서 3 이 나오면 정의가 틀린 것. */ + /** + * 한 기록이 여러 링크에 담길 수 있다 — 대상 구성이 다르면 링크는 공존한다(V31). 그래도 + * "공유 중인 기록" 은 하나다. 여기서 3 이 나오면 행을 세고 있다는 뜻이다. + */ @Test void countsARecordOnceEvenWhenSeveralLinksPointAtIt() { - UUID recordId = insertRecord(OWNER_ID); - insertShare(OWNER_ID, recordId, "ACTIVE", NOW.plusSeconds(60)); - insertShare(OWNER_ID, recordId, "ACTIVE", NOW.plusSeconds(120)); - insertShare(OWNER_ID, recordId, "ACTIVE", NOW.plusSeconds(180)); + UUID shared = insertRecord(OWNER_ID); + insertShare(OWNER_ID, shared, "ACTIVE", NOW.plusSeconds(60)); + insertShare(OWNER_ID, shared, "ACTIVE", NOW.plusSeconds(120)); + insertShare(OWNER_ID, shared, "ACTIVE", NOW.plusSeconds(180)); assertThat(adapter.count(OWNER_ID, NOW).sharedRecordCount()).isEqualTo(1); } @@ -135,8 +138,9 @@ INSERT INTO saved_styles (saved_style_id, user_id, style_name) private UUID insertShare(UUID ownerId, UUID recordId, String status, Instant expiresAt) { UUID shareId = UUID.randomUUID(); jdbcTemplate.update(""" - INSERT INTO shares (share_id, user_id, token_hash, status, expires_at) - VALUES (?, ?, ?, ?, ?) + INSERT INTO shares ( + share_id, user_id, token_hash, target_hash, status, expires_at + ) VALUES (?, ?, ?, md5(random()::text), ?, ?) """, shareId, ownerId, UUID.randomUUID().toString().replace("-", ""), status, Timestamp.from(expiresAt)); if (recordId != null) { diff --git a/src/test/java/com/heddy/application/account/service/AccountDeletionWorkerIntegrationTest.java b/src/test/java/com/heddy/application/account/service/AccountDeletionWorkerIntegrationTest.java index a744cad..67c34ae 100644 --- a/src/test/java/com/heddy/application/account/service/AccountDeletionWorkerIntegrationTest.java +++ b/src/test/java/com/heddy/application/account/service/AccountDeletionWorkerIntegrationTest.java @@ -117,8 +117,8 @@ INSERT INTO saved_styles ( UUID shareId = UUID.randomUUID(); jdbcTemplate.update(""" INSERT INTO shares ( - share_id, user_id, token_hash, status, expires_at - ) VALUES (?, ?, ?, 'ACTIVE', now() + interval '1 day') + share_id, user_id, token_hash, target_hash, status, expires_at + ) VALUES (?, ?, ?, md5(random()::text), 'ACTIVE', now() + interval '1 day') """, shareId, userId, UUID.randomUUID().toString().replace("-", "") + UUID.randomUUID().toString().replace("-", "")); jdbcTemplate.update(""" diff --git a/src/test/java/com/heddy/application/sharing/service/ShareServiceTest.java b/src/test/java/com/heddy/application/sharing/service/ShareServiceTest.java index 4d1e30c..2604415 100644 --- a/src/test/java/com/heddy/application/sharing/service/ShareServiceTest.java +++ b/src/test/java/com/heddy/application/sharing/service/ShareServiceTest.java @@ -23,6 +23,7 @@ import com.heddy.global.error.ErrorCode; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.InOrder; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.Mock; @@ -42,6 +43,8 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verifyNoInteractions; @@ -168,6 +171,29 @@ void rejectsSelectionWithoutFieldsBeforeOwnershipQueries() { then(treatmentRecordRepositoryPort).shouldHaveNoInteractions(); } + /** + * 폐기가 반드시 저장보다 먼저다. 순서가 뒤집히면 같은 대상의 활성 링크가 잠깐 둘이 되고, + * 부분 유니크 인덱스(V31)에 걸려 발급 자체가 실패한다. + */ + @Test + void closesThePreviousLinkBeforeInsertingTheNewOne() { + UUID recordId = UUID.randomUUID(); + given(treatmentRecordRepositoryPort.findByIdAndUserId(recordId, USER_ID)) + .willReturn(Optional.of(mock(TreatmentRecord.class))); + given(tokenGeneratorPort.generate()).willReturn("raw-token"); + given(tokenHasherPort.hash("raw-token")).willReturn("hashed"); + given(shareRepositoryPort.insert(any(Share.class))) + .willAnswer(invocation -> invocation.getArgument(0)); + + service.create(new CreateShareUseCase.Command( + USER_ID, Set.of(recordId), Set.of(), Set.of(ShareFieldType.PHOTOS), 7)); + + InOrder inOrder = inOrder(shareRepositoryPort); + inOrder.verify(shareRepositoryPort).revokeActiveWithSameTarget( + eq(USER_ID), eq(recordId + "|"), any(Instant.class)); + inOrder.verify(shareRepositoryPort).insert(any(Share.class)); + } + // ------------------------------------------------------------------ 목록 @Test diff --git a/src/test/java/com/heddy/domain/sharing/model/ShareTest.java b/src/test/java/com/heddy/domain/sharing/model/ShareTest.java index 46467bd..5e38a3f 100644 --- a/src/test/java/com/heddy/domain/sharing/model/ShareTest.java +++ b/src/test/java/com/heddy/domain/sharing/model/ShareTest.java @@ -5,6 +5,8 @@ import org.junit.jupiter.api.Test; import java.time.Instant; +import java.util.LinkedHashSet; +import java.util.List; import java.util.Set; import java.util.UUID; @@ -128,6 +130,32 @@ void refusesUpdateToPastOrPresentExpiry() { e -> assertThat(e.error()).isEqualTo(SharingError.EXPIRES_AT_NOT_FUTURE)); } + /** 고르는 순서는 대상의 일부가 아니다. 순서가 남으면 같은 조합으로 링크가 무한히 쌓인다. */ + @Test + void buildsTheSameTargetKeyRegardlessOfSelectionOrder() { + UUID first = UUID.fromString("11111111-1111-4111-8111-111111111111"); + UUID second = UUID.fromString("22222222-2222-4222-8222-222222222222"); + + Share ascending = Share.create(USER_ID, TOKEN_HASH, + new LinkedHashSet<>(List.of(first, second)), Set.of(), fields(), 1, NOW); + Share descending = Share.create(USER_ID, TOKEN_HASH, + new LinkedHashSet<>(List.of(second, first)), Set.of(), fields(), 1, NOW); + + assertThat(ascending.targetKey()).isEqualTo(descending.targetKey()); + assertThat(ascending.targetKey()).isEqualTo(first + "," + second + "|"); + } + + /** 기록만 고른 공유와 후보만 고른 공유는 다른 대상이다. 구분자가 없으면 둘이 섞인다. */ + @Test + void separatesRecordsFromSavedStylesInTheTargetKey() { + UUID id = UUID.randomUUID(); + + Share byRecord = Share.create(USER_ID, TOKEN_HASH, Set.of(id), Set.of(), fields(), 1, NOW); + Share byStyle = Share.create(USER_ID, TOKEN_HASH, Set.of(), Set.of(id), fields(), 1, NOW); + + assertThat(byRecord.targetKey()).isNotEqualTo(byStyle.targetKey()); + } + private static Set fields() { return Set.of(ShareFieldType.PHOTOS, ShareFieldType.TREATMENT_DETAILS); }