From 17005c51a7515452b8eebca19dcbceaf0b3649d1 Mon Sep 17 00:00:00 2001 From: Vedran Pavic Date: Wed, 1 May 2019 19:58:07 +0200 Subject: [PATCH] Add simple Redis SessionRepository implementation See: #1408 --- ...edisOperationsSessionRepositoryITests.java | 244 +++++++++++++ ...impleRedisOperationsSessionRepository.java | 297 +++++++++++++++ ...RedisOperationsSessionRepositoryTests.java | 343 ++++++++++++++++++ 3 files changed, 884 insertions(+) create mode 100644 spring-session-data-redis/src/integration-test/java/org/springframework/session/data/redis/SimpleRedisOperationsSessionRepositoryITests.java create mode 100644 spring-session-data-redis/src/main/java/org/springframework/session/data/redis/SimpleRedisOperationsSessionRepository.java create mode 100644 spring-session-data-redis/src/test/java/org/springframework/session/data/redis/SimpleRedisOperationsSessionRepositoryTests.java diff --git a/spring-session-data-redis/src/integration-test/java/org/springframework/session/data/redis/SimpleRedisOperationsSessionRepositoryITests.java b/spring-session-data-redis/src/integration-test/java/org/springframework/session/data/redis/SimpleRedisOperationsSessionRepositoryITests.java new file mode 100644 index 00000000..9b18edb4 --- /dev/null +++ b/spring-session-data-redis/src/integration-test/java/org/springframework/session/data/redis/SimpleRedisOperationsSessionRepositoryITests.java @@ -0,0 +1,244 @@ +/* + * Copyright 2014-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.session.data.redis; + +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Collections; +import java.util.UUID; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.session.MapSession; +import org.springframework.session.config.annotation.web.http.EnableSpringHttpSession; +import org.springframework.session.data.redis.SimpleRedisOperationsSessionRepository.RedisSession; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.context.web.WebAppConfiguration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; + +/** + * Integration tests for {@link SimpleRedisOperationsSessionRepository}. + * + * @author Vedran Pavic + */ +@ExtendWith(SpringExtension.class) +@ContextConfiguration +@WebAppConfiguration +class SimpleRedisOperationsSessionRepositoryITests extends AbstractRedisITests { + + @Autowired + private SimpleRedisOperationsSessionRepository sessionRepository; + + @Test + void save_NewSession_ShouldSaveSession() { + RedisSession session = createAndSaveSession(Instant.now()); + assertThat(session.getMaxInactiveInterval()).isEqualTo( + Duration.ofSeconds(MapSession.DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS)); + assertThat(session.getAttributeNames()) + .isEqualTo(Collections.singleton("attribute1")); + assertThat(session.getAttribute("attribute1")).isEqualTo("value1"); + } + + @Test + void save_LastAccessedTimeInPast_ShouldExpireSession() { + assertThat(createAndSaveSession(Instant.EPOCH)).isNull(); + } + + @Test + void save_DeletedSession_ShouldThrowException() { + RedisSession session = createAndSaveSession(Instant.now()); + this.sessionRepository.deleteById(session.getId()); + assertThatIllegalStateException() + .isThrownBy(() -> this.sessionRepository.save(session)) + .withMessage("Session was invalidated"); + } + + @Test + void save_ConcurrentUpdates_ShouldSaveSession() { + RedisSession copy1 = createAndSaveSession(Instant.now()); + String sessionId = copy1.getId(); + RedisSession copy2 = this.sessionRepository.findById(sessionId); + Instant now = Instant.now().truncatedTo(ChronoUnit.MILLIS); + updateSession(copy1, now.plusSeconds(1L), "attribute2", "value2"); + this.sessionRepository.save(copy1); + updateSession(copy2, now.plusSeconds(2L), "attribute3", "value3"); + this.sessionRepository.save(copy2); + RedisSession session = this.sessionRepository.findById(sessionId); + assertThat(session.getLastAccessedTime()).isEqualTo(now.plusSeconds(2L)); + assertThat(session.getAttributeNames()).hasSize(3); + assertThat(session.getAttribute("attribute1")).isEqualTo("value1"); + assertThat(session.getAttribute("attribute2")).isEqualTo("value2"); + assertThat(session.getAttribute("attribute3")).isEqualTo("value3"); + } + + @Test + void save_ChangeSessionIdAndUpdateAttribute_ShouldChangeSessionId() { + RedisSession session = createAndSaveSession(Instant.now()); + String originalSessionId = session.getId(); + updateSession(session, Instant.now(), "attribute1", "value2"); + String newSessionId = session.changeSessionId(); + this.sessionRepository.save(session); + RedisSession loaded = this.sessionRepository.findById(newSessionId); + assertThat(loaded).isNotNull(); + assertThat(loaded.getAttributeNames()).hasSize(1); + assertThat(loaded.getAttribute("attribute1")).isEqualTo("value2"); + assertThat(this.sessionRepository.findById(originalSessionId)).isNull(); + } + + @Test + void save_OnlyChangeSessionId_ShouldChangeSessionId() { + RedisSession session = createAndSaveSession(Instant.now()); + String originalSessionId = session.getId(); + String newSessionId = session.changeSessionId(); + this.sessionRepository.save(session); + assertThat(this.sessionRepository.findById(newSessionId)).isNotNull(); + assertThat(this.sessionRepository.findById(originalSessionId)).isNull(); + } + + @Test + void save_ChangeSessionIdTwice_ShouldChangeSessionId() { + RedisSession session = createAndSaveSession(Instant.now()); + String originalSessionId = session.getId(); + updateSession(session, Instant.now(), "attribute1", "value2"); + String newSessionId1 = session.changeSessionId(); + updateSession(session, Instant.now(), "attribute1", "value3"); + String newSessionId2 = session.changeSessionId(); + this.sessionRepository.save(session); + assertThat(this.sessionRepository.findById(newSessionId1)).isNull(); + assertThat(this.sessionRepository.findById(newSessionId2)).isNotNull(); + assertThat(this.sessionRepository.findById(originalSessionId)).isNull(); + } + + @Test + void save_ChangeSessionIdOnNewSession_ShouldChangeSessionId() { + RedisSession session = this.sessionRepository.createSession(); + String originalSessionId = session.getId(); + updateSession(session, Instant.now(), "attribute1", "value1"); + String newSessionId = session.changeSessionId(); + this.sessionRepository.save(session); + assertThat(this.sessionRepository.findById(newSessionId)).isNotNull(); + assertThat(this.sessionRepository.findById(originalSessionId)).isNull(); + } + + @Test + void save_ChangeSessionIdSaveTwice_ShouldChangeSessionId() { + RedisSession session = createAndSaveSession(Instant.now()); + String originalSessionId; + originalSessionId = session.getId(); + updateSession(session, Instant.now(), "attribute1", "value1"); + String newSessionId = session.changeSessionId(); + this.sessionRepository.save(session); + this.sessionRepository.save(session); + assertThat(this.sessionRepository.findById(newSessionId)).isNotNull(); + assertThat(this.sessionRepository.findById(originalSessionId)).isNull(); + } + + @Test + void save_ChangeSessionIdOnDeletedSession_ShouldThrowException() { + RedisSession session = createAndSaveSession(Instant.now()); + String originalSessionId = session.getId(); + this.sessionRepository.deleteById(originalSessionId); + updateSession(session, Instant.now(), "attribute1", "value1"); + String newSessionId = session.changeSessionId(); + assertThatIllegalStateException() + .isThrownBy(() -> this.sessionRepository.save(session)) + .withMessage("Session was invalidated"); + assertThat(this.sessionRepository.findById(newSessionId)).isNull(); + assertThat(this.sessionRepository.findById(originalSessionId)).isNull(); + } + + @Test + void save_ChangeSessionIdConcurrent_ShouldThrowException() { + RedisSession copy1 = createAndSaveSession(Instant.now()); + String originalSessionId = copy1.getId(); + RedisSession copy2 = this.sessionRepository.findById(originalSessionId); + Instant now = Instant.now().truncatedTo(ChronoUnit.MILLIS); + updateSession(copy1, now.plusSeconds(1L), "attribute2", "value2"); + String newSessionId1 = copy1.changeSessionId(); + this.sessionRepository.save(copy1); + updateSession(copy2, now.plusSeconds(2L), "attribute3", "value3"); + String newSessionId2 = copy2.changeSessionId(); + assertThatIllegalStateException() + .isThrownBy(() -> this.sessionRepository.save(copy2)) + .withMessage("Session was invalidated"); + assertThat(this.sessionRepository.findById(newSessionId1)).isNotNull(); + assertThat(this.sessionRepository.findById(newSessionId2)).isNull(); + assertThat(this.sessionRepository.findById(originalSessionId)).isNull(); + } + + @Test + void deleteById_ValidSession_ShouldDeleteSession() { + RedisSession session = createAndSaveSession(Instant.now()); + this.sessionRepository.deleteById(session.getId()); + assertThat(this.sessionRepository.findById(session.getId())).isNull(); + } + + @Test + void deleteById_DeletedSession_ShouldDoNothing() { + RedisSession session = createAndSaveSession(Instant.now()); + this.sessionRepository.deleteById(session.getId()); + this.sessionRepository.deleteById(session.getId()); + assertThat(this.sessionRepository.findById(session.getId())).isNull(); + } + + @Test + void deleteById_NonexistentSession_ShouldDoNothing() { + String sessionId = UUID.randomUUID().toString(); + this.sessionRepository.deleteById(sessionId); + assertThat(this.sessionRepository.findById(sessionId)).isNull(); + } + + private RedisSession createAndSaveSession(Instant lastAccessedTime) { + RedisSession session = this.sessionRepository.createSession(); + session.setLastAccessedTime(lastAccessedTime); + session.setAttribute("attribute1", "value1"); + this.sessionRepository.save(session); + return this.sessionRepository.findById(session.getId()); + } + + private static void updateSession(RedisSession session, Instant lastAccessedTime, + String attributeName, Object attributeValue) { + session.setLastAccessedTime(lastAccessedTime); + session.setAttribute(attributeName, attributeValue); + } + + @Configuration + @EnableSpringHttpSession + static class Config extends BaseConfig { + + @Bean + public SimpleRedisOperationsSessionRepository sessionRepository( + RedisConnectionFactory redisConnectionFactory) { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory); + redisTemplate.afterPropertiesSet(); + return new SimpleRedisOperationsSessionRepository(redisTemplate); + } + + } + +} diff --git a/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/SimpleRedisOperationsSessionRepository.java b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/SimpleRedisOperationsSessionRepository.java new file mode 100644 index 00000000..3eb0829a --- /dev/null +++ b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/SimpleRedisOperationsSessionRepository.java @@ -0,0 +1,297 @@ +/* + * Copyright 2014-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.session.data.redis; + +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +import org.springframework.data.redis.core.RedisOperations; +import org.springframework.session.MapSession; +import org.springframework.session.Session; +import org.springframework.session.SessionRepository; +import org.springframework.util.Assert; + +/** + * A {@link SessionRepository} implementation that uses Spring Data's + * {@link RedisOperations} to store sessions is Redis. + *

+ * This implementation does not support publishing of session events. + * + * @author Vedran Pavic + * @since 2.2.0 + */ +public class SimpleRedisOperationsSessionRepository implements + SessionRepository { + + private static final String DEFAULT_KEY_NAMESPACE = "spring:session:"; + + private final RedisOperations sessionRedisOperations; + + private Duration defaultMaxInactiveInterval = Duration + .ofSeconds(MapSession.DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS); + + private String keyNamespace = DEFAULT_KEY_NAMESPACE; + + private RedisFlushMode flushMode = RedisFlushMode.ON_SAVE; + + /** + * Create a new {@link SimpleRedisOperationsSessionRepository} instance. + * @param sessionRedisOperations the {@link RedisOperations} to use for managing + * sessions + */ + public SimpleRedisOperationsSessionRepository( + RedisOperations sessionRedisOperations) { + Assert.notNull(sessionRedisOperations, "sessionRedisOperations mut not be null"); + this.sessionRedisOperations = sessionRedisOperations; + } + + /** + * Set the default maxInactiveInterval. + * @param defaultMaxInactiveInterval the default maxInactiveInterval + */ + public void setDefaultMaxInactiveInterval(Duration defaultMaxInactiveInterval) { + Assert.notNull(defaultMaxInactiveInterval, + "defaultMaxInactiveInterval must not be null"); + this.defaultMaxInactiveInterval = defaultMaxInactiveInterval; + } + + /** + * Set the key namespace. + * @param keyNamespace the key namespace + */ + public void setKeyNamespace(String keyNamespace) { + Assert.hasText(keyNamespace, "keyNamespace must not be empty"); + this.keyNamespace = keyNamespace; + } + + /** + * Set the flush mode. + * @param flushMode the flush mode + */ + public void setFlushMode(RedisFlushMode flushMode) { + Assert.notNull(flushMode, "flushMode must not be null"); + this.flushMode = flushMode; + } + + @Override + public RedisSession createSession() { + RedisSession session = new RedisSession(this.defaultMaxInactiveInterval); + session.flushIfRequired(); + return session; + } + + @Override + public void save(RedisSession session) { + if (!session.isNew) { + String key = getSessionKey( + session.hasChangedSessionId() ? session.originalSessionId + : session.getId()); + Boolean sessionExists = this.sessionRedisOperations.hasKey(key); + if (sessionExists == null || !sessionExists) { + throw new IllegalStateException("Session was invalidated"); + } + } + session.save(); + } + + @Override + public RedisSession findById(String sessionId) { + String key = getSessionKey(sessionId); + Map entries = this.sessionRedisOperations + .opsForHash().entries(key); + if (entries.isEmpty()) { + return null; + } + MapSession session = new RedisSessionMapper(sessionId).apply(entries); + if (session.isExpired()) { + deleteById(sessionId); + return null; + } + return new RedisSession(session); + } + + @Override + public void deleteById(String sessionId) { + String key = getSessionKey(sessionId); + this.sessionRedisOperations.delete(key); + } + + /** + * Returns the {@link RedisOperations} used for sessions. + * @return the {@link RedisOperations} used for sessions + */ + public RedisOperations getSessionRedisOperations() { + return this.sessionRedisOperations; + } + + private String getSessionKey(String sessionId) { + return this.keyNamespace + "sessions:" + sessionId; + } + + /** + * An internal {@link Session} implementation used by this {@link SessionRepository}. + */ + final class RedisSession implements Session { + + private final MapSession cached; + + private final Map delta = new HashMap<>(); + + private boolean isNew; + + private String originalSessionId; + + RedisSession(Duration maxInactiveInterval) { + this(new MapSession()); + this.cached.setMaxInactiveInterval(maxInactiveInterval); + this.delta.put(RedisSessionMapper.CREATION_TIME_KEY, + getCreationTime().toEpochMilli()); + this.delta.put(RedisSessionMapper.MAX_INACTIVE_INTERVAL_KEY, + (int) getMaxInactiveInterval().getSeconds()); + this.delta.put(RedisSessionMapper.LAST_ACCESSED_TIME_KEY, + getLastAccessedTime().toEpochMilli()); + this.isNew = true; + } + + RedisSession(MapSession cached) { + this.cached = cached; + this.originalSessionId = cached.getId(); + } + + @Override + public String getId() { + return this.cached.getId(); + } + + @Override + public String changeSessionId() { + return this.cached.changeSessionId(); + } + + @Override + public T getAttribute(String attributeName) { + return this.cached.getAttribute(attributeName); + } + + @Override + public Set getAttributeNames() { + return this.cached.getAttributeNames(); + } + + @Override + public void setAttribute(String attributeName, Object attributeValue) { + this.cached.setAttribute(attributeName, attributeValue); + putAttribute(RedisSessionMapper.ATTRIBUTE_PREFIX + attributeName, + attributeValue); + } + + @Override + public void removeAttribute(String attributeName) { + setAttribute(attributeName, null); + } + + @Override + public Instant getCreationTime() { + return this.cached.getCreationTime(); + } + + @Override + public void setLastAccessedTime(Instant lastAccessedTime) { + this.cached.setLastAccessedTime(lastAccessedTime); + putAttribute(RedisSessionMapper.LAST_ACCESSED_TIME_KEY, + getLastAccessedTime().toEpochMilli()); + } + + @Override + public Instant getLastAccessedTime() { + return this.cached.getLastAccessedTime(); + } + + @Override + public void setMaxInactiveInterval(Duration interval) { + this.cached.setMaxInactiveInterval(interval); + putAttribute(RedisSessionMapper.MAX_INACTIVE_INTERVAL_KEY, + (int) getMaxInactiveInterval().getSeconds()); + } + + @Override + public Duration getMaxInactiveInterval() { + return this.cached.getMaxInactiveInterval(); + } + + @Override + public boolean isExpired() { + return this.cached.isExpired(); + } + + private void flushIfRequired() { + if (SimpleRedisOperationsSessionRepository.this.flushMode == RedisFlushMode.IMMEDIATE) { + save(); + } + } + + private boolean hasChangedSessionId() { + return !getId().equals(this.originalSessionId); + } + + private void save() { + saveChangeSessionId(); + saveDelta(); + if (this.isNew) { + this.isNew = false; + } + } + + private void saveChangeSessionId() { + if (hasChangedSessionId()) { + if (!this.isNew) { + String originalSessionIdKey = getSessionKey(this.originalSessionId); + String sessionIdKey = getSessionKey(getId()); + SimpleRedisOperationsSessionRepository.this.sessionRedisOperations + .rename(originalSessionIdKey, sessionIdKey); + } + this.originalSessionId = getId(); + } + } + + private void saveDelta() { + if (this.delta.isEmpty()) { + return; + } + String key = getSessionKey(getId()); + SimpleRedisOperationsSessionRepository.this.sessionRedisOperations + .opsForHash().putAll(key, new HashMap<>(this.delta)); + SimpleRedisOperationsSessionRepository.this.sessionRedisOperations + .expireAt(key, + Date.from(Instant + .ofEpochMilli(getLastAccessedTime().toEpochMilli()) + .plusSeconds(getMaxInactiveInterval().getSeconds()))); + this.delta.clear(); + } + + private void putAttribute(String name, Object value) { + this.delta.put(name, value); + flushIfRequired(); + } + + } + +} diff --git a/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/SimpleRedisOperationsSessionRepositoryTests.java b/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/SimpleRedisOperationsSessionRepositoryTests.java new file mode 100644 index 00000000..52e7b2b9 --- /dev/null +++ b/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/SimpleRedisOperationsSessionRepositoryTests.java @@ -0,0 +1,343 @@ +/* + * Copyright 2014-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.session.data.redis; + +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import org.springframework.data.redis.core.HashOperations; +import org.springframework.data.redis.core.RedisOperations; +import org.springframework.session.MapSession; +import org.springframework.session.data.redis.SimpleRedisOperationsSessionRepository.RedisSession; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +/** + * Tests for {@link SimpleRedisOperationsSessionRepository}. + * + * @author Vedran Pavic + */ +class SimpleRedisOperationsSessionRepositoryTests { + + private static final String TEST_SESSION_ID = "session-id"; + + private static final String TEST_SESSION_KEY = getSessionKey(TEST_SESSION_ID); + + @Mock + private RedisOperations sessionRedisOperations; + + @Mock + private HashOperations sessionHashOperations; + + @Captor + private ArgumentCaptor> delta; + + private SimpleRedisOperationsSessionRepository sessionRepository; + + @BeforeEach + void setUp() { + MockitoAnnotations.initMocks(this); + given(this.sessionRedisOperations.opsForHash()) + .willReturn(this.sessionHashOperations); + this.sessionRepository = new SimpleRedisOperationsSessionRepository( + this.sessionRedisOperations); + } + + @Test + void constructor_NullRedisOperations_ShouldThrowException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new ReactiveRedisOperationsSessionRepository(null)) + .withMessageContaining("sessionRedisOperations cannot be null"); + } + + @Test + void setDefaultMaxInactiveInterval_ValidInterval_ShouldSetInterval() { + this.sessionRepository.setDefaultMaxInactiveInterval(Duration.ofMinutes(10)); + assertThat(ReflectionTestUtils.getField(this.sessionRepository, + "defaultMaxInactiveInterval")).isEqualTo(Duration.ofMinutes(10)); + } + + @Test + void setDefaultMaxInactiveInterval_NullInterval_ShouldThrowException() { + assertThatIllegalArgumentException() + .isThrownBy( + () -> this.sessionRepository.setDefaultMaxInactiveInterval(null)) + .withMessage("defaultMaxInactiveInterval must not be null"); + } + + @Test + void setKeyNamespace_ValidNamespace_ShouldSetNamespace() { + this.sessionRepository.setKeyNamespace("test:"); + assertThat(ReflectionTestUtils.getField(this.sessionRepository, "keyNamespace")) + .isEqualTo("test:"); + } + + @Test + void setKeyNamespace_NullNamespace_ShouldThrowException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.sessionRepository.setKeyNamespace(null)) + .withMessage("keyNamespace must not be empty"); + } + + @Test + void setKeyNamespace_EmptyNamespace_ShouldThrowException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.sessionRepository.setKeyNamespace(" ")) + .withMessage("keyNamespace must not be empty"); + } + + @Test + void setFlushMode_ValidFlushMode_ShouldSetFlushMode() { + this.sessionRepository.setFlushMode(RedisFlushMode.IMMEDIATE); + assertThat(ReflectionTestUtils.getField(this.sessionRepository, "flushMode")) + .isEqualTo(RedisFlushMode.IMMEDIATE); + } + + @Test + void setFlushMode_NullFlushMode_ShouldThrowException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.sessionRepository.setFlushMode(null)) + .withMessage("flushMode must not be null"); + } + + @Test + void createSession_DefaultMaxInactiveInterval_ShouldCreateSession() { + RedisSession redisSession = this.sessionRepository.createSession(); + assertThat(redisSession.getMaxInactiveInterval()).isEqualTo( + Duration.ofSeconds(MapSession.DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS)); + verifyNoMoreInteractions(this.sessionRedisOperations); + verifyNoMoreInteractions(this.sessionHashOperations); + } + + @Test + void createSession_CustomMaxInactiveInterval_ShouldCreateSession() { + this.sessionRepository.setDefaultMaxInactiveInterval(Duration.ofMinutes(10)); + RedisSession redisSession = this.sessionRepository.createSession(); + assertThat(redisSession.getMaxInactiveInterval()) + .isEqualTo(Duration.ofMinutes(10)); + verifyNoMoreInteractions(this.sessionRedisOperations); + verifyNoMoreInteractions(this.sessionHashOperations); + } + + @Test + void createSession_ImmediateFlushMode_ShouldCreateSession() { + this.sessionRepository.setFlushMode(RedisFlushMode.IMMEDIATE); + RedisSession session = this.sessionRepository.createSession(); + String key = getSessionKey(session.getId()); + verify(this.sessionRedisOperations).opsForHash(); + verify(this.sessionRedisOperations).expireAt(eq(key), eq(getExpiry(session))); + verify(this.sessionHashOperations).putAll(eq(key), this.delta.capture()); + assertThat(this.delta.getValue()).hasSize(3); + verifyNoMoreInteractions(this.sessionRedisOperations); + verifyNoMoreInteractions(this.sessionHashOperations); + } + + @Test + void save_NewSession_ShouldSaveSession() { + RedisSession session = this.sessionRepository.createSession(); + this.sessionRepository.save(session); + String key = getSessionKey(session.getId()); + verify(this.sessionRedisOperations).opsForHash(); + verify(this.sessionRedisOperations).expireAt(eq(key), eq(getExpiry(session))); + verify(this.sessionHashOperations).putAll(eq(key), this.delta.capture()); + assertThat(this.delta.getValue()).hasSize(3); + verifyNoMoreInteractions(this.sessionRedisOperations); + verifyNoMoreInteractions(this.sessionHashOperations); + } + + @Test + void save_NewSessionAndCustomKeyNamespace_ShouldSaveSession() { + this.sessionRepository.setKeyNamespace("custom:"); + RedisSession session = this.sessionRepository.createSession(); + this.sessionRepository.save(session); + String key = "custom:sessions:" + session.getId(); + verify(this.sessionRedisOperations).opsForHash(); + verify(this.sessionRedisOperations).expireAt(eq(key), eq(getExpiry(session))); + verify(this.sessionHashOperations).putAll(eq(key), this.delta.capture()); + assertThat(this.delta.getValue()).hasSize(3); + verifyNoMoreInteractions(this.sessionRedisOperations); + verifyNoMoreInteractions(this.sessionHashOperations); + } + + @Test + void save_NewSessionAndChangedSessionId_ShouldSaveSession() { + RedisSession session = this.sessionRepository.createSession(); + session.changeSessionId(); + this.sessionRepository.save(session); + String key = getSessionKey(session.getId()); + verify(this.sessionRedisOperations).opsForHash(); + verify(this.sessionRedisOperations).expireAt(eq(key), eq(getExpiry(session))); + verify(this.sessionHashOperations).putAll(eq(key), this.delta.capture()); + assertThat(this.delta.getValue()).hasSize(3); + verifyNoMoreInteractions(this.sessionRedisOperations); + verifyNoMoreInteractions(this.sessionHashOperations); + } + + @Test + void save_SessionExistsAndHasChanges_ShouldSaveSession() { + given(this.sessionRedisOperations.hasKey(eq(TEST_SESSION_KEY))).willReturn(true); + RedisSession session = createTestSession(); + session.setLastAccessedTime(Instant.now()); + session.setAttribute("attribute2", "value2"); + this.sessionRepository.save(session); + verify(this.sessionRedisOperations).hasKey(eq(TEST_SESSION_KEY)); + verify(this.sessionRedisOperations).opsForHash(); + verify(this.sessionRedisOperations).expireAt(eq(TEST_SESSION_KEY), + eq(getExpiry(session))); + verify(this.sessionHashOperations).putAll(eq(TEST_SESSION_KEY), + this.delta.capture()); + assertThat(this.delta.getValue()).hasSize(2); + verifyNoMoreInteractions(this.sessionRedisOperations); + verifyNoMoreInteractions(this.sessionHashOperations); + } + + @Test + void save_SessionExistsAndNoChanges_ShouldSaveSession() { + given(this.sessionRedisOperations.hasKey(eq(TEST_SESSION_KEY))).willReturn(true); + RedisSession session = createTestSession(); + this.sessionRepository.save(session); + verify(this.sessionRedisOperations).hasKey(eq(TEST_SESSION_KEY)); + verifyNoMoreInteractions(this.sessionRedisOperations); + verifyNoMoreInteractions(this.sessionHashOperations); + } + + @Test + void save_SessionNotExists_ShouldThrowException() { + RedisSession session = createTestSession(); + assertThatIllegalStateException() + .isThrownBy(() -> this.sessionRepository.save(session)) + .withMessage("Session was invalidated"); + verify(this.sessionRedisOperations).hasKey(eq(TEST_SESSION_KEY)); + verifyNoMoreInteractions(this.sessionRedisOperations); + verifyNoMoreInteractions(this.sessionHashOperations); + } + + @Test + @SuppressWarnings("unchecked") + void findById_SessionExists_ShouldReturnSession() { + Instant now = Instant.now().truncatedTo(ChronoUnit.MILLIS); + given(this.sessionHashOperations.entries(eq(TEST_SESSION_KEY))).willReturn( + mapOf(RedisSessionMapper.CREATION_TIME_KEY, Instant.EPOCH.toEpochMilli(), + RedisSessionMapper.LAST_ACCESSED_TIME_KEY, now.toEpochMilli(), + RedisSessionMapper.MAX_INACTIVE_INTERVAL_KEY, + MapSession.DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS, + RedisSessionMapper.ATTRIBUTE_PREFIX + "attribute1", "value1")); + RedisSession session = this.sessionRepository.findById(TEST_SESSION_ID); + assertThat(session.getId()).isEqualTo(TEST_SESSION_ID); + assertThat(session.getCreationTime()).isEqualTo(Instant.EPOCH); + assertThat(session.getLastAccessedTime()).isEqualTo(now); + assertThat(session.getMaxInactiveInterval()).isEqualTo( + Duration.ofSeconds(MapSession.DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS)); + assertThat(session.getAttributeNames()) + .isEqualTo(Collections.singleton("attribute1")); + assertThat(session.getAttribute("attribute1")).isEqualTo("value1"); + verify(this.sessionRedisOperations).opsForHash(); + verify(this.sessionHashOperations).entries(eq(TEST_SESSION_KEY)); + verifyNoMoreInteractions(this.sessionRedisOperations); + verifyNoMoreInteractions(this.sessionHashOperations); + } + + @Test + @SuppressWarnings("unchecked") + void findById_SessionExistsAndIsExpired_ShouldReturnNull() { + given(this.sessionHashOperations.entries(eq(TEST_SESSION_KEY))).willReturn(mapOf( + RedisSessionMapper.CREATION_TIME_KEY, Instant.EPOCH.toEpochMilli(), + RedisSessionMapper.LAST_ACCESSED_TIME_KEY, Instant.EPOCH.toEpochMilli(), + RedisSessionMapper.MAX_INACTIVE_INTERVAL_KEY, + MapSession.DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS, + RedisSessionMapper.ATTRIBUTE_PREFIX + "attribute1", "value1")); + assertThat(this.sessionRepository.findById(TEST_SESSION_ID)).isNull(); + verify(this.sessionRedisOperations).opsForHash(); + verify(this.sessionHashOperations).entries(eq(TEST_SESSION_KEY)); + verify(this.sessionRedisOperations).delete(eq(TEST_SESSION_KEY)); + verifyNoMoreInteractions(this.sessionRedisOperations); + verifyNoMoreInteractions(this.sessionHashOperations); + } + + @Test + void findById_SessionNotExists_ShouldReturnNull() { + assertThat(this.sessionRepository.findById(TEST_SESSION_ID)).isNull(); + verify(this.sessionRedisOperations).opsForHash(); + verify(this.sessionHashOperations).entries(eq(TEST_SESSION_KEY)); + verifyNoMoreInteractions(this.sessionRedisOperations); + verifyNoMoreInteractions(this.sessionHashOperations); + } + + @Test + void deleteById__ShouldDeleteSession() { + this.sessionRepository.deleteById(TEST_SESSION_ID); + verify(this.sessionRedisOperations).delete(TEST_SESSION_KEY); + verifyNoMoreInteractions(this.sessionRedisOperations); + verifyNoMoreInteractions(this.sessionHashOperations); + } + + @Test + void getSessionRedisOperations__ShouldReturnRedisOperations() { + assertThat(this.sessionRepository.getSessionRedisOperations()) + .isEqualTo(this.sessionRedisOperations); + verifyNoMoreInteractions(this.sessionRedisOperations); + verifyNoMoreInteractions(this.sessionHashOperations); + } + + private static String getSessionKey(String sessionId) { + return "spring:session:sessions:" + sessionId; + } + + private static Date getExpiry(RedisSession session) { + return Date + .from(Instant.ofEpochMilli(session.getLastAccessedTime().toEpochMilli()) + .plusSeconds(session.getMaxInactiveInterval().getSeconds())); + } + + private static Map mapOf(Object... objects) { + Map result = new HashMap<>(); + if (objects != null) { + for (int i = 0; i < objects.length; i += 2) { + result.put((String) objects[i], objects[i + 1]); + } + } + return result; + } + + private RedisSession createTestSession() { + MapSession cached = new MapSession(TEST_SESSION_ID); + cached.setCreationTime(Instant.EPOCH); + cached.setLastAccessedTime(Instant.EPOCH); + cached.setAttribute("attribute1", "value1"); + return this.sessionRepository.new RedisSession(cached); + } + +}