keycloak/testsuite/model/src/test/java/org/keycloak/testsuite/model/transaction/StorageTransactionTest.java

370 lines
16 KiB
Java

/*
* Copyright 2023 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* 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
*
* http://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.keycloak.testsuite.model.transaction;
import org.junit.Test;
import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.ModelException;
import org.keycloak.models.RealmModel;
import org.keycloak.models.RealmProvider;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.models.locking.LockAcquiringTimeoutException;
import org.keycloak.models.map.storage.MapStorageProvider;
import org.keycloak.models.map.storage.hotRod.HotRodMapStorageProviderFactory;
import org.keycloak.models.map.storage.jpa.JpaMapStorageProviderFactory;
import org.keycloak.testsuite.model.KeycloakModelTest;
import org.keycloak.testsuite.model.RequireProvider;
import org.keycloak.testsuite.model.util.TransactionController;
import org.keycloak.utils.LockObjectsForModification;
import javax.persistence.OptimisticLockException;
import javax.persistence.PessimisticLockException;
import javax.transaction.RollbackException;
import java.util.UUID;
import java.util.function.Function;
import static org.hamcrest.CoreMatchers.allOf;
import static org.hamcrest.CoreMatchers.anyOf;
import static org.hamcrest.CoreMatchers.equalTo;
import static org.hamcrest.CoreMatchers.instanceOf;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.internal.matchers.ThrowableCauseMatcher.hasCause;
import static org.keycloak.testsuite.model.util.KeycloakAssertions.assertException;
@RequireProvider(RealmProvider.class)
public class StorageTransactionTest extends KeycloakModelTest {
// System variable is used to simplify configuration for more storages that support pessimistic locking.
// Instead of searching which storage is used and then configure its factory, we can just configure
// lockTimeout like this: .config("lockTimeout", "${keycloak.model.tests.lockTimeout:}") and
// system property will be picked when factory is reinitialized.
public static final String LOCK_TIMEOUT_SYSTEM_PROPERTY = "keycloak.model.tests.lockTimeout";
private String realmId;
@Override
protected void createEnvironment(KeycloakSession s) {
RealmModel r = s.realms().createRealm("1");
r.setDefaultRole(s.roles().addRealmRole(r, Constants.DEFAULT_ROLES_ROLE_PREFIX + "-" + r.getName()));
r.setAttribute("k1", "v1");
r.setSsoSessionIdleTimeout(1000);
r.setSsoSessionMaxLifespan(2000);
realmId = r.getId();
}
@Override
protected void cleanEnvironment(KeycloakSession s) {
s.realms().removeRealm(realmId);
}
@Override
protected boolean isUseSameKeycloakSessionFactoryForAllThreads() {
return true;
}
@Test
public void testTwoTransactionsSequentially() throws Exception {
try (TransactionController tx1 = new TransactionController(getFactory());
TransactionController tx2 = new TransactionController(getFactory())) {
tx1.begin();
assertThat(
tx1.runStep(session -> {
session.realms().getRealm(realmId).setAttribute("k2", "v1");
return session.realms().getRealm(realmId).getAttribute("k2");
}), equalTo("v1"));
tx1.commit();
tx2.begin();
assertThat(
tx2.runStep(session -> session.realms().getRealm(realmId).getAttribute("k2")),
equalTo("v1"));
tx2.commit();
}
}
@Test
public void testRepeatableRead() throws Exception {
try (TransactionController tx1 = new TransactionController(getFactory());
TransactionController tx2 = new TransactionController(getFactory());
TransactionController tx3 = new TransactionController(getFactory())) {
tx1.begin();
tx2.begin();
tx3.begin();
// Read original value in tx1
assertThat(
tx1.runStep(session -> session.realms().getRealm(realmId).getAttribute("k1")),
equalTo("v1"));
// change value to new in tx2
tx2.runStep(session -> {
session.realms().getRealm(realmId).setAttribute("k1", "v2");
return null;
});
tx2.commit();
// tx1 should still return the value that already read
assertThat(
tx1.runStep(session -> session.realms().getRealm(realmId).getAttribute("k1")),
equalTo("v1"));
// tx3 should return the new value
assertThat(
tx3.runStep(session -> session.realms().getRealm(realmId).getAttribute("k1")),
equalTo("v2"));
tx1.commit();
tx3.commit();
}
}
@Test
// LockObjectForModification currently works only in map-jpa and map-hotrod
@RequireProvider(value = MapStorageProvider.class, only = {JpaMapStorageProviderFactory.PROVIDER_ID, HotRodMapStorageProviderFactory.PROVIDER_ID})
public void testLockObjectForModificationById() throws Exception {
testLockObjectForModification(session -> LockObjectsForModification.lockRealmsForModification(session, () -> session.realms().getRealm(realmId)));
}
@Test
// LockObjectForModification currently works only in map-jpa and map-hotrod
@RequireProvider(value = MapStorageProvider.class, only = {JpaMapStorageProviderFactory.PROVIDER_ID, HotRodMapStorageProviderFactory.PROVIDER_ID})
public void testLockUserSessionForModificationByQuery() throws Exception {
// Create user session
final String sessionId = withRealm(realmId, (session, realm) -> {
UserModel myUser = session.users().addUser(realm, "myUser");
return session.sessions().createUserSession(realm, myUser, "myUser", "127.0.0.1", "form", true, null, null).getId();
});
testLockObjectForModification(session -> LockObjectsForModification.lockUserSessionsForModification(session, readUserSessionByIdUsingQueryParameters(session, sessionId)));
}
private <R> void testLockObjectForModification(Function<KeycloakSession, R> lockedExecution) throws Exception {
String originalTimeoutValue = System.getProperty(LOCK_TIMEOUT_SYSTEM_PROPERTY);
try {
System.setProperty(LOCK_TIMEOUT_SYSTEM_PROPERTY, "300");
reinitializeKeycloakSessionFactory();
try (TransactionController tx1 = new TransactionController(getFactory());
TransactionController tx2 = new TransactionController(getFactory());
TransactionController tx3 = new TransactionController(getFactory())) {
tx1.begin();
tx2.begin();
// tx1 acquires lock
tx1.runStep(lockedExecution);
// tx2 should fail as tx1 locked the realm
assertException(() -> tx2.runStep(lockedExecution),
anyOf(allOf(instanceOf(ModelException.class), hasCause(anyOf(instanceOf(PessimisticLockException.class), instanceOf(org.hibernate.PessimisticLockException.class)))),
instanceOf(LockAcquiringTimeoutException.class)));
// end both transactions
tx2.rollback();
tx1.commit();
// start new transaction and read again, it should be successful
tx3.begin();
tx3.runStep(lockedExecution);
tx3.commit();
}
} finally {
if (originalTimeoutValue == null) {
System.clearProperty(LOCK_TIMEOUT_SYSTEM_PROPERTY);
} else {
System.setProperty(LOCK_TIMEOUT_SYSTEM_PROPERTY, originalTimeoutValue);
}
reinitializeKeycloakSessionFactory();
}
}
private LockObjectsForModification.CallableWithoutThrowingAnException<UserSessionModel> readUserSessionByIdUsingQueryParameters(KeycloakSession session, String sessionId) {
RealmModel realm = session.realms().getRealm(realmId);
return () -> session.sessions().getUserSession(realm, sessionId);
}
@Test
// Optimistic locking works only with map-jpa and map-hotrod
@RequireProvider(value = MapStorageProvider.class, only = {JpaMapStorageProviderFactory.PROVIDER_ID,
HotRodMapStorageProviderFactory.PROVIDER_ID})
public void testOptimisticLockingExceptionReadById() throws Exception {
withRealm(realmId, (session, realm) -> {
realm.setDisplayName("displayName1");
return null;
});
try (TransactionController tx1 = new TransactionController(getFactory());
TransactionController tx2 = new TransactionController(getFactory())) {
// tx1 acquires lock
tx1.begin();
tx2.begin();
// both transactions touch the same entity
tx1.runStep(session -> {
session.realms().getRealm(realmId).setDisplayName("displayName2");
return null;
});
tx2.runStep(session -> {
session.realms().getRealm(realmId).setDisplayName("displayName3");
return null;
});
// tx1 transaction should be successful
tx1.commit();
// tx2 should fail as tx1 already changed the value
assertException(tx2::commit,
anyOf(
allOf(instanceOf(RuntimeException.class), hasCause(instanceOf(RollbackException.class))),
allOf(instanceOf(ModelException.class), hasCause(instanceOf(OptimisticLockException.class))),
allOf(instanceOf(OptimisticLockException.class))
));
}
}
@Test
// Optimistic locking works only with map-jpa and map-hotrod
@RequireProvider(value = MapStorageProvider.class, only = {JpaMapStorageProviderFactory.PROVIDER_ID,
HotRodMapStorageProviderFactory.PROVIDER_ID})
public void testOptimisticLockingExceptionReadByQuery() throws Exception {
withRealm(realmId, (session, realm) -> {
realm.setDisplayName("displayName1");
return null;
});
try (TransactionController tx1 = new TransactionController(getFactory());
TransactionController tx2 = new TransactionController(getFactory())) {
// tx1 acquires lock
tx1.begin();
tx2.begin();
// both transactions touch the same entity
tx1.runStep(session -> {
session.realms().getRealmByName("1").setDisplayName("displayName2");
return null;
});
tx2.runStep(session -> {
session.realms().getRealmByName("1").setDisplayName("displayName3");
return null;
});
// tx1 transaction should be successful
tx1.commit();
// tx2 should fail as tx1 already changed the value
assertException(tx2::commit,
anyOf(
allOf(instanceOf(RuntimeException.class), hasCause(instanceOf(RollbackException.class))),
allOf(instanceOf(ModelException.class), hasCause(instanceOf(OptimisticLockException.class))),
allOf(instanceOf(OptimisticLockException.class))
));
}
}
@Test
// Optimistic locking works only with map-jpa and map-hotrod
@RequireProvider(value = MapStorageProvider.class, only = {JpaMapStorageProviderFactory.PROVIDER_ID,
HotRodMapStorageProviderFactory.PROVIDER_ID})
public void testOptimisticLockingDeleteWhenReadingByQuery() throws Exception {
withRealm(realmId, (session, realm) -> {
session.users().addUser(realm, "user", "user", false, false);
return null;
});
try (TransactionController tx1 = new TransactionController(getFactory());
TransactionController tx2 = new TransactionController(getFactory())) {
tx1.begin();
tx2.begin();
// both transactions touch the same entity
tx1.runStep(session -> {
// read by criteria
session.users().getUserByUsername(session.realms().getRealm(realmId), "user").setFirstName("firstName");
return null;
});
tx2.runStep(session -> {
RealmModel realm = session.realms().getRealm(realmId);
// remove by id
session.users().removeUser(realm, session.users().getUserByUsername(realm, "user"));
return null;
});
// tx1 transaction should be successful
tx1.commit();
// tx2 should fail as tx1 already changed the value
assertException(tx2::commit,
anyOf(
allOf(instanceOf(RuntimeException.class), hasCause(instanceOf(RollbackException.class))),
allOf(instanceOf(ModelException.class), hasCause(instanceOf(OptimisticLockException.class))),
allOf(instanceOf(OptimisticLockException.class))
));
}
}
@Test
// Optimistic locking works only with map-jpa and map-hotrod
@RequireProvider(value = MapStorageProvider.class, only = {JpaMapStorageProviderFactory.PROVIDER_ID,
HotRodMapStorageProviderFactory.PROVIDER_ID})
public void testOptimisticLockingDeleteWhenReadingById() throws Exception {
String userId = UUID.randomUUID().toString();
withRealm(realmId, (session, realm) -> {
session.users().addUser(realm, userId, "user", false, false);
return null;
});
try (TransactionController tx1 = new TransactionController(getFactory());
TransactionController tx2 = new TransactionController(getFactory())) {
tx1.begin();
tx2.begin();
// both transactions touch the same entity
tx1.runStep(session -> {
// read by id
session.users().getUserById(session.realms().getRealm(realmId), userId).setFirstName("firstName");
return null;
});
tx2.runStep(session -> {
RealmModel realm = session.realms().getRealm(realmId);
// remove by id after read by id
session.users().removeUser(realm, session.users().getUserById(realm, userId));
return null;
});
// tx1 transaction should be successful
tx1.commit();
// tx2 should fail as tx1 already changed the value
assertException(tx2::commit,
anyOf(
allOf(instanceOf(RuntimeException.class), hasCause(instanceOf(RollbackException.class))),
allOf(instanceOf(ModelException.class), hasCause(instanceOf(OptimisticLockException.class))),
allOf(instanceOf(OptimisticLockException.class))
));
}
}
}