Skip to content

Commit fb9694a

Browse files
committed
test(security): integration tests proving token-replay guard and GDPR after-commit fix
- AbstractConcurrentTokenConsumeTest (+ PostgreSQL/MariaDB Testcontainers subclasses): two threads race to consume the same password-reset and verification token; assert exactly one wins and the token is gone. Proves the conditional-DELETE single-use guard holds under real concurrency on production-grade databases (not just H2). - GdprDeletionAfterCommitIntegrationTest: proves executeUserDeletion now runs through the transactional proxy — the UserDeletedEvent is delivered from the afterCommit synchronization (synchronization active, user already deleted), and on a contributor failure the deletion rolls back atomically and NO event is published. These are the integration-level proofs the third-pass review requested for findings P1 (token replay) and P2 (GDPR atomicity).
1 parent 5649436 commit fb9694a

4 files changed

Lines changed: 426 additions & 0 deletions

File tree

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
package com.digitalsanctuary.spring.user.gdpr;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
import java.util.Map;
5+
import java.util.concurrent.atomic.AtomicBoolean;
6+
import org.junit.jupiter.api.AfterEach;
7+
import org.junit.jupiter.api.DisplayName;
8+
import org.junit.jupiter.api.Test;
9+
import org.springframework.beans.factory.annotation.Autowired;
10+
import org.springframework.boot.test.context.SpringBootTest;
11+
import org.springframework.boot.test.context.TestConfiguration;
12+
import org.springframework.context.annotation.Bean;
13+
import org.springframework.context.annotation.Import;
14+
import org.springframework.context.event.EventListener;
15+
import org.springframework.test.context.ActiveProfiles;
16+
import org.springframework.transaction.support.TransactionSynchronizationManager;
17+
import com.digitalsanctuary.spring.user.event.UserDeletedEvent;
18+
import com.digitalsanctuary.spring.user.persistence.model.User;
19+
import com.digitalsanctuary.spring.user.persistence.repository.UserRepository;
20+
import com.digitalsanctuary.spring.user.test.app.TestApplication;
21+
import com.digitalsanctuary.spring.user.test.builders.UserTestDataBuilder;
22+
23+
/**
24+
* Integration test proving that {@link GdprDeletionService} actually runs the deletion inside a transaction and
25+
* publishes {@link UserDeletedEvent} only <em>after that transaction commits</em>. This is the integration-level
26+
* proof for the self-invocation fix: {@code deleteUser} now invokes {@code executeUserDeletion} through the Spring
27+
* proxy ({@code self}), so the {@code @Transactional} boundary is honored. A unit test cannot verify this — the
28+
* transactional proxy only exists in a real context.
29+
*
30+
* <p>
31+
* This test is deliberately <strong>not</strong> {@code @Transactional}: the service must run and commit (or roll
32+
* back) its own transaction so the after-commit synchronization fires for real. Two behaviors are asserted:
33+
* </p>
34+
* <ul>
35+
* <li><b>Happy path:</b> the user row is committed (gone) by the time the event listener runs, and no transaction is
36+
* active during event delivery — i.e. the event is genuinely after-commit, not mid-transaction.</li>
37+
* <li><b>Rollback path:</b> when a data contributor throws, the whole deletion rolls back (the user still exists) and
38+
* <em>no</em> {@link UserDeletedEvent} is published — proving the event is not emitted on a failed/partial delete.</li>
39+
* </ul>
40+
*/
41+
@SpringBootTest(classes = TestApplication.class)
42+
@ActiveProfiles("test")
43+
@Import(GdprDeletionAfterCommitIntegrationTest.TestBeans.class)
44+
@DisplayName("GdprDeletionService after-commit / rollback integration")
45+
class GdprDeletionAfterCommitIntegrationTest {
46+
47+
@Autowired
48+
private GdprDeletionService gdprDeletionService;
49+
50+
@Autowired
51+
private UserRepository userRepository;
52+
53+
@Autowired
54+
private DeletedEventRecorder recorder;
55+
56+
@Autowired
57+
private TogglableFailingContributor failingContributor;
58+
59+
@AfterEach
60+
void cleanUp() {
61+
// No test-managed transaction here, so clean up committed rows and reset shared test state.
62+
userRepository.deleteAll();
63+
recorder.reset();
64+
failingContributor.disarm();
65+
}
66+
67+
@Test
68+
@DisplayName("publishes UserDeletedEvent AFTER the deletion transaction commits")
69+
void publishesEventAfterCommit() {
70+
User user = userRepository.save(UserTestDataBuilder.aUser()
71+
.withId(null) // let the DB assign the id so save() performs an INSERT, not a merge
72+
.withEmail("after-commit-" + System.nanoTime() + "@test.com")
73+
.withFirstName("After").withLastName("Commit").enabled().build());
74+
Long userId = user.getId();
75+
76+
GdprDeletionService.DeletionResult result = gdprDeletionService.deleteUser(user, false);
77+
78+
assertThat(result.isSuccess()).as("deletion should succeed").isTrue();
79+
assertThat(recorder.isReceived()).as("UserDeletedEvent should be published").isTrue();
80+
// The whole point of the fix: by the time the event fires, the delete has COMMITTED.
81+
assertThat(recorder.wasUserAbsentAtEventTime())
82+
.as("user row must already be deleted when the event is delivered").isTrue();
83+
// Distinguishes the fix from the bug: the event is delivered from within the transaction's afterCommit
84+
// synchronization (synchronization active). In the broken self-invocation version there was no transaction,
85+
// so the event published immediately with NO synchronization active and this would be false.
86+
assertThat(recorder.wasSynchronizationActiveAtEventTime())
87+
.as("event must be delivered inside the transaction's afterCommit synchronization").isTrue();
88+
assertThat(userRepository.findById(userId)).as("user is deleted").isEmpty();
89+
}
90+
91+
@Test
92+
@DisplayName("rolls back the deletion and publishes NO event when a contributor fails")
93+
void rollsBackAndPublishesNoEventOnFailure() {
94+
failingContributor.arm();
95+
User user = userRepository.save(UserTestDataBuilder.aUser()
96+
.withId(null) // let the DB assign the id so save() performs an INSERT, not a merge
97+
.withEmail("rollback-" + System.nanoTime() + "@test.com")
98+
.withFirstName("Roll").withLastName("Back").enabled().build());
99+
Long userId = user.getId();
100+
101+
GdprDeletionService.DeletionResult result = gdprDeletionService.deleteUser(user, false);
102+
103+
assertThat(result.isSuccess()).as("deletion should fail when a contributor throws").isFalse();
104+
assertThat(recorder.isReceived())
105+
.as("no UserDeletedEvent may be published when the transaction rolled back").isFalse();
106+
assertThat(userRepository.findById(userId))
107+
.as("the user must still exist — the deletion transaction rolled back atomically").isPresent();
108+
}
109+
110+
@TestConfiguration
111+
static class TestBeans {
112+
@Bean
113+
DeletedEventRecorder deletedEventRecorder(UserRepository userRepository) {
114+
return new DeletedEventRecorder(userRepository);
115+
}
116+
117+
@Bean
118+
TogglableFailingContributor togglableFailingContributor() {
119+
return new TogglableFailingContributor();
120+
}
121+
}
122+
123+
/**
124+
* Records receipt of {@link UserDeletedEvent} and captures, at event-delivery time, whether the user row is
125+
* already gone (committed) and whether a transaction is still active. Used to prove after-commit semantics.
126+
*/
127+
static class DeletedEventRecorder {
128+
private final UserRepository userRepository;
129+
private volatile boolean received;
130+
private volatile boolean userAbsentAtEventTime;
131+
private volatile boolean synchronizationActiveAtEventTime;
132+
133+
DeletedEventRecorder(UserRepository userRepository) {
134+
this.userRepository = userRepository;
135+
}
136+
137+
@EventListener
138+
void onUserDeleted(UserDeletedEvent event) {
139+
received = true;
140+
synchronizationActiveAtEventTime = TransactionSynchronizationManager.isSynchronizationActive();
141+
userAbsentAtEventTime = userRepository.findById(event.getUserId()).isEmpty();
142+
}
143+
144+
boolean isReceived() {
145+
return received;
146+
}
147+
148+
boolean wasUserAbsentAtEventTime() {
149+
return userAbsentAtEventTime;
150+
}
151+
152+
boolean wasSynchronizationActiveAtEventTime() {
153+
return synchronizationActiveAtEventTime;
154+
}
155+
156+
void reset() {
157+
received = false;
158+
userAbsentAtEventTime = false;
159+
synchronizationActiveAtEventTime = false;
160+
}
161+
}
162+
163+
/**
164+
* A {@link GdprDataContributor} that throws during {@code prepareForDeletion} only when armed, used to force a
165+
* transaction rollback in the middle of the deletion.
166+
*/
167+
static class TogglableFailingContributor implements GdprDataContributor {
168+
private final AtomicBoolean armed = new AtomicBoolean(false);
169+
170+
void arm() {
171+
armed.set(true);
172+
}
173+
174+
void disarm() {
175+
armed.set(false);
176+
}
177+
178+
@Override
179+
public String getDataKey() {
180+
return "test-failing-contributor";
181+
}
182+
183+
@Override
184+
public Map<String, Object> exportUserData(User user) {
185+
return Map.of();
186+
}
187+
188+
@Override
189+
public void prepareForDeletion(User user) {
190+
if (armed.get()) {
191+
throw new RuntimeException("Simulated contributor failure to force rollback");
192+
}
193+
}
194+
}
195+
}
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
package com.digitalsanctuary.spring.user.service;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
import com.digitalsanctuary.spring.user.persistence.model.PasswordResetToken;
5+
import com.digitalsanctuary.spring.user.persistence.model.User;
6+
import com.digitalsanctuary.spring.user.persistence.model.VerificationToken;
7+
import com.digitalsanctuary.spring.user.persistence.repository.PasswordResetTokenRepository;
8+
import com.digitalsanctuary.spring.user.persistence.repository.UserRepository;
9+
import com.digitalsanctuary.spring.user.persistence.repository.VerificationTokenRepository;
10+
import com.digitalsanctuary.spring.user.test.app.TestApplication;
11+
import com.digitalsanctuary.spring.user.test.builders.UserTestDataBuilder;
12+
import java.util.ArrayList;
13+
import java.util.List;
14+
import java.util.concurrent.Callable;
15+
import java.util.concurrent.CountDownLatch;
16+
import java.util.concurrent.ExecutionException;
17+
import java.util.concurrent.ExecutorService;
18+
import java.util.concurrent.Executors;
19+
import java.util.concurrent.Future;
20+
import java.util.concurrent.TimeUnit;
21+
import java.util.concurrent.atomic.AtomicInteger;
22+
import org.junit.jupiter.api.AfterEach;
23+
import org.junit.jupiter.api.DisplayName;
24+
import org.junit.jupiter.api.RepeatedTest;
25+
import org.springframework.beans.factory.annotation.Autowired;
26+
import org.springframework.boot.test.context.SpringBootTest;
27+
import org.springframework.test.context.ActiveProfiles;
28+
29+
/**
30+
* Validates that single-use token consumption is truly atomic under concurrency on a real, production-grade database
31+
* (not just H2). The fix makes the conditional {@code DELETE} (which returns the affected row count) the atomicity
32+
* guard: the row lock serializes concurrent deletes, so exactly one caller observes a count of {@code 1} (and applies
33+
* the effect) while the rest observe {@code 0} (and are rejected). A plain read-check-delete would let two requests
34+
* both read the token under READ_COMMITTED and both succeed — the replay this test guards against.
35+
*
36+
* <p>
37+
* Two threads race to consume the SAME token at the same instant (released together via a {@link CountDownLatch}).
38+
* Exactly one must win. This is asserted for both the password-reset consume path
39+
* ({@link UserService#validateAndConsumePasswordResetToken(String)}) and the email-verification consume path
40+
* ({@link UserVerificationService#validateVerificationToken(String)}).
41+
* </p>
42+
*
43+
* <p>
44+
* Subclasses provide a real database via Testcontainers. The class is deliberately NOT {@code @Transactional}: each
45+
* consume must run in its own service-managed transaction on its own thread, and a test-managed transaction would
46+
* defeat the race.
47+
* </p>
48+
*/
49+
@SpringBootTest(classes = TestApplication.class)
50+
@ActiveProfiles("test")
51+
abstract class AbstractConcurrentTokenConsumeTest {
52+
53+
@Autowired
54+
private UserService userService;
55+
56+
@Autowired
57+
private UserVerificationService userVerificationService;
58+
59+
@Autowired
60+
private UserRepository userRepository;
61+
62+
@Autowired
63+
private PasswordResetTokenRepository passwordResetTokenRepository;
64+
65+
@Autowired
66+
private VerificationTokenRepository verificationTokenRepository;
67+
68+
@Autowired
69+
private TokenHasher tokenHasher;
70+
71+
@AfterEach
72+
void cleanUp() {
73+
// The threads commit their own transactions, so clean up explicitly.
74+
passwordResetTokenRepository.deleteAll();
75+
verificationTokenRepository.deleteAll();
76+
userRepository.deleteAll();
77+
}
78+
79+
@RepeatedTest(value = 3, name = "{displayName} [run {currentRepetition}/{totalRepetitions}]")
80+
@DisplayName("password-reset token is consumed by exactly one of two racing threads")
81+
void shouldConsumePasswordResetTokenExactlyOnceUnderConcurrency() throws InterruptedException {
82+
final User user = userRepository.save(UserTestDataBuilder.aUser()
83+
.withId(null) // let the DB assign the id so save() performs an INSERT, not a merge
84+
.withEmail("pwd-race-" + System.nanoTime() + "@test.com")
85+
.withFirstName("Pwd").withLastName("Race").enabled().build());
86+
final String rawToken = "pwd-reset-" + System.nanoTime();
87+
passwordResetTokenRepository.save(new PasswordResetToken(tokenHasher.hash(rawToken), user, 60));
88+
89+
final List<Object> outcomes = raceTwoThreads(() -> userService.validateAndConsumePasswordResetToken(rawToken));
90+
91+
final long wins = outcomes.stream().filter(o -> o instanceof User).count();
92+
assertThat(wins).as("exactly one thread may consume the password-reset token").isEqualTo(1);
93+
assertThat(passwordResetTokenRepository.findByToken(tokenHasher.hash(rawToken)))
94+
.as("the token must be gone after consumption").isNull();
95+
}
96+
97+
@RepeatedTest(value = 3, name = "{displayName} [run {currentRepetition}/{totalRepetitions}]")
98+
@DisplayName("verification token is consumed by exactly one of two racing threads")
99+
void shouldConsumeVerificationTokenExactlyOnceUnderConcurrency() throws InterruptedException {
100+
final User user = userRepository.save(UserTestDataBuilder.aUser()
101+
.withId(null) // let the DB assign the id so save() performs an INSERT, not a merge
102+
.withEmail("verify-race-" + System.nanoTime() + "@test.com")
103+
.withFirstName("Verify").withLastName("Race").unverified().build());
104+
final String rawToken = "verify-" + System.nanoTime();
105+
verificationTokenRepository.save(new VerificationToken(tokenHasher.hash(rawToken), user, 60));
106+
107+
final List<Object> outcomes =
108+
raceTwoThreads(() -> userVerificationService.validateVerificationToken(rawToken));
109+
110+
final long valid = outcomes.stream()
111+
.filter(o -> o == UserService.TokenValidationResult.VALID).count();
112+
assertThat(valid).as("exactly one thread may validate (consume) the verification token").isEqualTo(1);
113+
assertThat(verificationTokenRepository.findByToken(tokenHasher.hash(rawToken)))
114+
.as("the token must be gone after consumption").isNull();
115+
assertThat(userRepository.findById(user.getId()))
116+
.get().extracting(User::isEnabled).as("the user is enabled exactly once").isEqualTo(true);
117+
}
118+
119+
/**
120+
* Runs the given consume action on two threads released simultaneously and returns both outcomes.
121+
*
122+
* @param consume the consume action under test
123+
* @return the two outcomes (a result object, or {@code null} for the losing/rejected call)
124+
*/
125+
private List<Object> raceTwoThreads(final Callable<Object> consume) throws InterruptedException {
126+
final int threadCount = 2;
127+
final CountDownLatch readyLatch = new CountDownLatch(threadCount);
128+
final CountDownLatch startLatch = new CountDownLatch(1);
129+
final ExecutorService executor = Executors.newFixedThreadPool(threadCount);
130+
try {
131+
final List<Future<Object>> futures = new ArrayList<>();
132+
for (int i = 0; i < threadCount; i++) {
133+
futures.add(executor.submit(() -> {
134+
readyLatch.countDown();
135+
if (!startLatch.await(30, TimeUnit.SECONDS)) {
136+
throw new IllegalStateException("start gate was never opened");
137+
}
138+
return consume.call();
139+
}));
140+
}
141+
142+
assertThat(readyLatch.await(30, TimeUnit.SECONDS))
143+
.as("both consume threads should reach the start gate").isTrue();
144+
startLatch.countDown();
145+
146+
final AtomicInteger unexpected = new AtomicInteger();
147+
final List<Object> outcomes = new ArrayList<>();
148+
for (Future<Object> future : futures) {
149+
try {
150+
outcomes.add(future.get(60, TimeUnit.SECONDS));
151+
} catch (ExecutionException e) {
152+
unexpected.incrementAndGet();
153+
} catch (Exception e) {
154+
unexpected.incrementAndGet();
155+
}
156+
}
157+
assertThat(unexpected.get()).as("neither consume call should throw").isZero();
158+
return outcomes;
159+
} finally {
160+
executor.shutdownNow();
161+
}
162+
}
163+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package com.digitalsanctuary.spring.user.service;
2+
3+
import org.junit.jupiter.api.DisplayName;
4+
import org.springframework.test.context.DynamicPropertyRegistry;
5+
import org.springframework.test.context.DynamicPropertySource;
6+
import org.testcontainers.containers.MariaDBContainer;
7+
import org.testcontainers.junit.jupiter.Container;
8+
import org.testcontainers.junit.jupiter.Testcontainers;
9+
10+
/**
11+
* Runs the concurrent single-use token-consume race against a real MariaDB container, proving the conditional DELETE
12+
* guard prevents token replay under InnoDB's default isolation.
13+
*/
14+
@Testcontainers
15+
@DisplayName("MariaDB Concurrent Token Consume Tests")
16+
class MariaDBConcurrentTokenConsumeTest extends AbstractConcurrentTokenConsumeTest {
17+
18+
@Container
19+
static final MariaDBContainer<?> MARIADB = new MariaDBContainer<>("mariadb:11.4")
20+
.withDatabaseName("testdb")
21+
.withUsername("test")
22+
.withPassword("test");
23+
24+
@DynamicPropertySource
25+
static void configureProperties(DynamicPropertyRegistry registry) {
26+
registry.add("spring.datasource.url", MARIADB::getJdbcUrl);
27+
registry.add("spring.datasource.username", MARIADB::getUsername);
28+
registry.add("spring.datasource.password", MARIADB::getPassword);
29+
registry.add("spring.datasource.driver-class-name", () -> "org.mariadb.jdbc.Driver");
30+
registry.add("spring.jpa.hibernate.ddl-auto", () -> "create");
31+
registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.MariaDBDialect");
32+
registry.add("spring.jpa.properties.hibernate.dialect", () -> "org.hibernate.dialect.MariaDBDialect");
33+
}
34+
}

0 commit comments

Comments
 (0)