Add file based websession sharing

The implementation is a fork of the websession-flatfile plugin. This
fork was required to provide this all-in-one plugin allowing to run an
active/passive setup and keep the websession-flatfile plugin as is, i.e.
not deprecating it.

This implementation is similar to the original but reworked to be more
testable and many unit test cases added to keep the code-coverage of the
high-availability above 89%.

Change-Id: I302f67e9f3546cdcb8634ec0dd678be73f87f94d
diff --git a/BUCK b/BUCK
index dcbf446..7b63737 100644
--- a/BUCK
+++ b/BUCK
@@ -40,6 +40,7 @@
 java_test(
   name = 'high-availability_tests',
   srcs = glob(['src/test/java/**/*.java']),
+  resources = glob(['src/test/resources/**/']),
   labels = ['high-availability'],
   deps = TEST_DEPS,
 )
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/HttpModule.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/HttpModule.java
index 4a83e92..6ccbae2 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/HttpModule.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/HttpModule.java
@@ -17,10 +17,12 @@
 import com.google.gerrit.httpd.plugins.HttpPluginModule;
 
 import com.ericsson.gerrit.plugins.highavailability.forwarder.rest.RestForwarderServletModule;
+import com.ericsson.gerrit.plugins.highavailability.websession.file.FileBasedWebsessionModule;
 
 class HttpModule extends HttpPluginModule {
   @Override
   protected void configureServlets() {
     install(new RestForwarderServletModule());
+    install(new FileBasedWebsessionModule());
   }
 }
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/websession/file/CleanupIntervalMillis.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/websession/file/CleanupIntervalMillis.java
new file mode 100644
index 0000000..57ce2b6
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/websession/file/CleanupIntervalMillis.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// 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 com.ericsson.gerrit.plugins.highavailability.websession.file;
+
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.google.inject.BindingAnnotation;
+
+import java.lang.annotation.Retention;
+
+@Retention(RUNTIME)
+@BindingAnnotation
+public @interface CleanupIntervalMillis {
+}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/websession/file/FileBasedWebSession.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/websession/file/FileBasedWebSession.java
new file mode 100644
index 0000000..7322ef7
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/websession/file/FileBasedWebSession.java
@@ -0,0 +1,44 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// 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 com.ericsson.gerrit.plugins.highavailability.websession.file;
+
+import com.google.gerrit.extensions.annotations.RootRelative;
+import com.google.gerrit.httpd.CacheBasedWebSession;
+import com.google.gerrit.httpd.WebSessionManagerFactory;
+import com.google.gerrit.server.AnonymousUser;
+import com.google.gerrit.server.IdentifiedUser.RequestFactory;
+import com.google.gerrit.server.config.AuthConfig;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.servlet.RequestScoped;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+@RequestScoped
+public class FileBasedWebSession extends CacheBasedWebSession {
+
+  @Inject
+  FileBasedWebSession(@RootRelative Provider<HttpServletRequest> request,
+      @RootRelative Provider<HttpServletResponse> response,
+      WebSessionManagerFactory managerFactory,
+      FileBasedWebsessionCache cache,
+      AuthConfig authConfig,
+      Provider<AnonymousUser> anonymousProvider,
+      RequestFactory identified) {
+    super(request.get(), response.get(), managerFactory.create(cache),
+        authConfig, anonymousProvider, identified);
+  }
+}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/websession/file/FileBasedWebSessionCacheCleaner.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/websession/file/FileBasedWebSessionCacheCleaner.java
new file mode 100644
index 0000000..ea3ebb2
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/websession/file/FileBasedWebSessionCacheCleaner.java
@@ -0,0 +1,90 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// 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 com.ericsson.gerrit.plugins.highavailability.websession.file;
+
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import com.google.gerrit.extensions.annotations.PluginName;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.concurrent.ScheduledFuture;
+
+@Singleton
+class FileBasedWebSessionCacheCleaner implements LifecycleListener {
+
+  private final WorkQueue queue;
+  private final Provider<CleanupTask> cleanupTaskProvider;
+  private final long cleanupIntervalMillis;
+  private ScheduledFuture<?> scheduledCleanupTask;
+
+  @Inject
+  FileBasedWebSessionCacheCleaner(WorkQueue queue,
+      Provider<CleanupTask> cleanupTaskProvider,
+      @CleanupIntervalMillis long cleanupIntervalMillis) {
+    this.queue = queue;
+    this.cleanupTaskProvider = cleanupTaskProvider;
+    this.cleanupIntervalMillis = cleanupIntervalMillis;
+  }
+
+  @Override
+  public void start() {
+    scheduledCleanupTask =
+        queue.getDefaultQueue().scheduleAtFixedRate(cleanupTaskProvider.get(),
+            SECONDS.toMillis(1), cleanupIntervalMillis, MILLISECONDS);
+  }
+
+  @Override
+  public void stop() {
+    if (scheduledCleanupTask != null) {
+      scheduledCleanupTask.cancel(true);
+      scheduledCleanupTask = null;
+    }
+  }
+}
+
+class CleanupTask implements Runnable {
+  private static final Logger logger =
+      LoggerFactory.getLogger(CleanupTask.class);
+  private final FileBasedWebsessionCache fileBasedWebSessionCache;
+  private final String pluginName;
+
+  @Inject
+  CleanupTask(FileBasedWebsessionCache fileBasedWebSessionCache,
+      @PluginName String pluginName) {
+    this.fileBasedWebSessionCache = fileBasedWebSessionCache;
+    this.pluginName = pluginName;
+  }
+
+  @Override
+  public void run() {
+    logger.info("Cleaning up expired file based websessions...");
+    fileBasedWebSessionCache.cleanUp();
+    logger.info("Cleaning up expired file based websessions...Done");
+  }
+
+  @Override
+  public String toString() {
+    return String.format("[%s] Clean up expired file based websessions",
+        pluginName);
+  }
+}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/websession/file/FileBasedWebsessionCache.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/websession/file/FileBasedWebsessionCache.java
new file mode 100644
index 0000000..137a7ae
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/websession/file/FileBasedWebsessionCache.java
@@ -0,0 +1,218 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// 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 com.ericsson.gerrit.plugins.highavailability.websession.file;
+
+import com.google.common.cache.Cache;
+import com.google.common.cache.CacheStats;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.httpd.WebSessionManager;
+import com.google.gerrit.httpd.WebSessionManager.Val;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+import com.ericsson.gerrit.plugins.highavailability.SharedDirectory;
+
+import org.joda.time.DateTime;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.io.OutputStream;
+import java.nio.file.DirectoryStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.UUID;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.ExecutionException;
+
+@Singleton
+public class FileBasedWebsessionCache
+    implements Cache<String, WebSessionManager.Val> {
+  private static final Logger log =
+      LoggerFactory.getLogger(FileBasedWebsessionCache.class);
+
+  private final Path websessionsDir;
+
+  @Inject
+  public FileBasedWebsessionCache(@SharedDirectory Path sharedDirectory)
+      throws IOException {
+    this.websessionsDir = sharedDirectory.resolve("websessions");
+    Files.createDirectories(websessionsDir);
+  }
+
+  @Override
+  public ConcurrentMap<String, Val> asMap() {
+    ConcurrentMap<String, Val> map = new ConcurrentHashMap<>();
+    for (Path path : listFiles()) {
+      Val v = readFile(path);
+      if (v != null) {
+        map.put(path.getFileName().toString(), v);
+      }
+    }
+    return map;
+  }
+
+  @Override
+  public void cleanUp() {
+    for (Path path : listFiles()) {
+      Val val = readFile(path);
+      DateTime expires = new DateTime(val.getExpiresAt());
+      if (expires.isBefore(new DateTime())) {
+        deleteFile(path);
+      }
+    }
+  }
+
+  @Override
+  public Val get(String key, Callable<? extends Val> valueLoader)
+      throws ExecutionException {
+    Val value = getIfPresent(key);
+    if (value == null) {
+      try {
+        value = valueLoader.call();
+      } catch (Exception e) {
+        throw new ExecutionException(e);
+      }
+    }
+    return value;
+  }
+
+  @Override
+  public ImmutableMap<String, Val> getAllPresent(Iterable<?> keys) {
+    ImmutableMap.Builder<String, Val> mapBuilder = new ImmutableMap.Builder<>();
+    for (Object key : keys) {
+      Val v = getIfPresent(key);
+      if (v != null) {
+        mapBuilder.put((String) key, v);
+      }
+    }
+    return mapBuilder.build();
+  }
+
+  @Override
+  @Nullable
+  public Val getIfPresent(Object key) {
+    if (key instanceof String) {
+      Path path = websessionsDir.resolve((String) key);
+      return readFile(path);
+    }
+    return null;
+  }
+
+  @Override
+  public void invalidate(Object key) {
+    if (key instanceof String) {
+      deleteFile(websessionsDir.resolve((String) key));
+    }
+  }
+
+  @Override
+  public void invalidateAll() {
+    for (Path path : listFiles()) {
+      deleteFile(path);
+    }
+  }
+
+  @Override
+  public void invalidateAll(Iterable<?> keys) {
+    for (Object key : keys) {
+      invalidate(key);
+    }
+  }
+
+  @Override
+  public void put(String key, Val value) {
+    try {
+      Path tempFile = Files.createTempFile(websessionsDir,
+          UUID.randomUUID().toString(), null);
+      try (OutputStream fileStream = Files.newOutputStream(tempFile);
+          ObjectOutputStream objStream = new ObjectOutputStream(fileStream)) {
+        objStream.writeObject(value);
+        Files.move(tempFile, tempFile.resolveSibling(key),
+            StandardCopyOption.REPLACE_EXISTING,
+            StandardCopyOption.ATOMIC_MOVE);
+      }
+    } catch (IOException e) {
+      log.warn("Cannot put into cache {}", websessionsDir, e);
+    }
+  }
+
+  @Override
+  public void putAll(Map<? extends String, ? extends Val> keys) {
+    for (Entry<? extends String, ? extends Val> e : keys.entrySet()) {
+      put(e.getKey(), e.getValue());
+    }
+  }
+
+  @Override
+  public long size() {
+    return listFiles().size();
+  }
+
+  @Override
+  public CacheStats stats() {
+    log.warn("stats() unimplemented");
+    return null;
+  }
+
+  private Val readFile(Path path) {
+    if (Files.exists(path)) {
+      try (InputStream fileStream = Files.newInputStream(path);
+          ObjectInputStream objStream = new ObjectInputStream(fileStream)) {
+        return (Val) objStream.readObject();
+      } catch (ClassNotFoundException e) {
+        log.warn("Entry {} in cache {} has an incompatible class and can't be"
+            + " deserialized. Invalidating entry.", path, websessionsDir);
+        log.debug(e.getMessage(), e);
+        invalidate(path.getFileName().toString());
+      } catch (IOException e) {
+        log.warn("Cannot read cache {}", websessionsDir, e);
+      }
+    }
+    return null;
+  }
+
+  private void deleteFile(Path path) {
+    try {
+      Files.deleteIfExists(path);
+    } catch (IOException e) {
+      log.error("Error trying to delete {} from {}", path, websessionsDir, e);
+    }
+  }
+
+  private List<Path> listFiles() {
+    List<Path> files = new ArrayList<>();
+    try (DirectoryStream<Path> dirStream =
+        Files.newDirectoryStream(websessionsDir)) {
+      for (Path path : dirStream) {
+        files.add(path);
+      }
+    } catch (IOException e) {
+      log.error("Cannot list files in cache {}", websessionsDir, e);
+    }
+    return files;
+  }
+}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/websession/file/FileBasedWebsessionModule.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/websession/file/FileBasedWebsessionModule.java
new file mode 100644
index 0000000..4ececb9
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/websession/file/FileBasedWebsessionModule.java
@@ -0,0 +1,50 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// 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 com.ericsson.gerrit.plugins.highavailability.websession.file;
+
+import static java.util.concurrent.TimeUnit.HOURS;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.annotations.PluginName;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.httpd.WebSession;
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.server.config.ConfigUtil;
+import com.google.gerrit.server.config.PluginConfigFactory;
+import com.google.inject.Provides;
+import com.google.inject.Singleton;
+import com.google.inject.servlet.RequestScoped;
+import com.google.inject.servlet.ServletScopes;
+
+public class FileBasedWebsessionModule extends LifecycleModule {
+  @Override
+  protected void configure() {
+    bindScope(RequestScoped.class, ServletScopes.REQUEST);
+    DynamicItem.bind(binder(), WebSession.class).to(FileBasedWebSession.class)
+        .in(RequestScoped.class);
+    listener().to(FileBasedWebSessionCacheCleaner.class);
+  }
+
+  @Provides
+  @Singleton
+  @CleanupIntervalMillis
+  Long getCleanupInterval(PluginConfigFactory cfg,
+      @PluginName String pluginName) {
+    String fromConfig = Strings.nullToEmpty(
+        cfg.getFromGerritConfig(pluginName, true).getString("cleanupInterval"));
+    return ConfigUtil.getTimeUnit(fromConfig, HOURS.toMillis(24), MILLISECONDS);
+  }
+}
diff --git a/src/main/resources/Documentation/about.md b/src/main/resources/Documentation/about.md
index f0f371b..dee47e2 100644
--- a/src/main/resources/Documentation/about.md
+++ b/src/main/resources/Documentation/about.md
@@ -1,11 +1,9 @@
-The @PLUGIN@ plugin allows to synchronize eviction of caches, secondary indexes
-and stream events between two Gerrit instances sharing the same git repositories
-and database. The plugin needs to be installed in both instances.
+The @PLUGIN@ plugin allows to synchronize eviction of caches, secondary indexes,
+stream events and websessions between two Gerrit instances sharing the same git
+repositories and database. The plugin needs to be installed in both instances.
 
 Every time a cache eviction occurs in one of the instances, the other instance's
-cache is updated.
-
-This way, both caches are kept synchronized.
+cache is updated. This way, both caches are kept synchronized.
 
 Every time the secondary index is modified in one of the instances, i.e., a
 change is added, updated or removed from the index, the other instance index is
@@ -17,6 +15,10 @@
 output of the stream-events command is the same, no matter what instance a
 client is connected to.
 
-For this to work, http must be enabled in both instances and the plugin
-must be configured with valid credentials. For further information, refer to
-[config](config.md) documentation.
+The built-in Gerrit H2 based websession cache is replaced with a file based
+implementation that is shared amongst both instance.
+
+For this to work, http must be enabled in both instances, the plugin must be
+configured with valid credentials and a shared directory must be accesssible
+from both instances. For further information, refer to [config](config.md)
+documentation.
diff --git a/src/test/java/com/ericsson/gerrit/plugins/highavailability/websession/file/FileBasedWebSessionCacheCleanerTest.java b/src/test/java/com/ericsson/gerrit/plugins/highavailability/websession/file/FileBasedWebSessionCacheCleanerTest.java
new file mode 100644
index 0000000..56342e6
--- /dev/null
+++ b/src/test/java/com/ericsson/gerrit/plugins/highavailability/websession/file/FileBasedWebSessionCacheCleanerTest.java
@@ -0,0 +1,106 @@
+// Copyright (C) 2017 Ericsson
+//
+// 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 com.ericsson.gerrit.plugins.highavailability.websession.file;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isA;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.git.WorkQueue.Executor;
+import com.google.inject.Provider;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+
+@RunWith(MockitoJUnitRunner.class)
+public class FileBasedWebSessionCacheCleanerTest {
+
+  private static long CLEANUP_INTERVAL = 5000;
+  private static String SOME_PLUGIN_NAME = "somePluginName";
+
+  @Mock
+  private Executor executorMock;
+  @Mock
+  private ScheduledFuture<?> scheduledFutureMock;
+  @Mock
+  private WorkQueue workQueueMock;
+  @Mock
+  private Provider<CleanupTask> cleanupTaskProviderMock;
+
+  private FileBasedWebSessionCacheCleaner cleaner;
+
+  @Before
+  public void setUp() {
+    when(cleanupTaskProviderMock.get()).thenReturn(new CleanupTask(null, null));
+    when(workQueueMock.getDefaultQueue()).thenReturn(executorMock);
+    doReturn(scheduledFutureMock).when(executorMock).scheduleAtFixedRate(
+        isA(CleanupTask.class), anyLong(), anyLong(), isA(TimeUnit.class));
+    cleaner = new FileBasedWebSessionCacheCleaner(workQueueMock,
+        cleanupTaskProviderMock, CLEANUP_INTERVAL);
+  }
+
+  @Test
+  public void testCleanupTaskRun() {
+    FileBasedWebsessionCache cacheMock = mock(FileBasedWebsessionCache.class);
+    CleanupTask task = new CleanupTask(cacheMock, null);
+    int numberOfRuns = 5;
+    for (int i = 0; i < numberOfRuns; i++) {
+      task.run();
+    }
+    verify(cacheMock, times(numberOfRuns)).cleanUp();
+  }
+
+  @Test
+  public void testCleanupTaskToString() {
+    CleanupTask task = new CleanupTask(null, SOME_PLUGIN_NAME);
+    assertThat(task.toString()).isEqualTo(String.format(
+        "[%s] Clean up expired file based websessions", SOME_PLUGIN_NAME));
+  }
+
+  @Test
+  public void testCleanupTaskIsScheduledOnStart() {
+    cleaner.start();
+    verify(executorMock, times(1)).scheduleAtFixedRate(isA(CleanupTask.class),
+        eq(1000l), eq(CLEANUP_INTERVAL), eq(TimeUnit.MILLISECONDS));
+  }
+
+  @Test
+  public void testCleanupTaskIsCancelledOnStop() {
+    cleaner.start();
+    cleaner.stop();
+    verify(scheduledFutureMock, times(1)).cancel(true);
+  }
+
+  @Test
+  public void testCleanupTaskIsCancelledOnlyOnce() {
+    cleaner.start();
+    cleaner.stop();
+    cleaner.stop();
+    verify(scheduledFutureMock, times(1)).cancel(true);
+  }
+}
diff --git a/src/test/java/com/ericsson/gerrit/plugins/highavailability/websession/file/FileBasedWebSessionCacheTest.java b/src/test/java/com/ericsson/gerrit/plugins/highavailability/websession/file/FileBasedWebSessionCacheTest.java
new file mode 100644
index 0000000..22e8da9
--- /dev/null
+++ b/src/test/java/com/ericsson/gerrit/plugins/highavailability/websession/file/FileBasedWebSessionCacheTest.java
@@ -0,0 +1,286 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// 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 com.ericsson.gerrit.plugins.highavailability.websession.file;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.httpd.WebSessionManager.Val;
+
+import org.joda.time.DateTime;
+import org.joda.time.DateTimeUtils;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.DirectoryStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+
+public class FileBasedWebSessionCacheTest {
+
+  private static final String EXISTING_KEY = "aSceprtBc02YaMY573T5jfW64ZudJfPbDq";
+  private static final String EMPTY_KEY = "aOc2prqlZRpSO3LpauGO5efCLs1L9r9KkG";
+  private static final String INVALID_KEY = "aOFdpHriBM6dN055M13PjDdTZagl5r5aSG";
+
+  @Rule
+  public TemporaryFolder tempFolder = new TemporaryFolder();
+
+  private FileBasedWebsessionCache cache;
+  private Path websessionDir;
+
+  @Before
+  public void setUp() throws Exception {
+    websessionDir = tempFolder.newFolder("websessions").toPath();
+    cache = new FileBasedWebsessionCache(tempFolder.getRoot().toPath());
+  }
+
+  @Test
+  public void asMapTest() throws Exception {
+    loadKeyToCacheDir(EMPTY_KEY);
+    assertThat(cache.asMap()).isEmpty();
+
+    loadKeyToCacheDir(INVALID_KEY);
+    assertThat(cache.asMap()).isEmpty();
+
+    loadKeyToCacheDir(EXISTING_KEY);
+    assertThat(cache.asMap()).containsKey(EXISTING_KEY);
+  }
+
+  @Test
+  public void constructorCreateDir() throws IOException {
+    assertThat(websessionDir.toFile().delete()).isTrue();
+    cache = new FileBasedWebsessionCache(tempFolder.getRoot().toPath());
+    assertThat(websessionDir.toFile().exists()).isTrue();
+  }
+
+  @Test
+  public void cleanUpTest() throws Exception {
+    loadKeyToCacheDir(EXISTING_KEY);
+    try {
+      long existingKeyExpireAt =
+          cache.getIfPresent(EXISTING_KEY).getExpiresAt();
+      DateTimeUtils.setCurrentMillisFixed(
+          new DateTime(existingKeyExpireAt).minusHours(1).getMillis());
+      cache.cleanUp();
+      assertThat(isDirEmpty(websessionDir)).isFalse();
+
+      DateTimeUtils.setCurrentMillisFixed(
+          new DateTime(existingKeyExpireAt).plusHours(1).getMillis());
+      cache.cleanUp();
+      assertThat(isDirEmpty(websessionDir)).isTrue();
+    } finally {
+      DateTimeUtils.setCurrentMillisSystem();
+    }
+  }
+
+  @Test
+  public void cleanUpWithErrorsWhileListingFilesTest() throws Exception {
+    tempFolder.delete();
+    cache.cleanUp();
+    assertThat(cache.size()).isEqualTo(0);
+  }
+
+  @Test
+  public void cleanUpWithErrorsWhileDeleteFileTest() throws Exception {
+    loadKeyToCacheDir(EXISTING_KEY);
+    try {
+      websessionDir.toFile().setWritable(false);
+      cache.cleanUp();
+      assertThat(cache.size()).isEqualTo(1);
+    } finally {
+      websessionDir.toFile().setWritable(true);
+    }
+  }
+
+  @Test
+  public void getIfPresentEmptyKeyTest() throws Exception {
+    assertThat(cache.getIfPresent(EMPTY_KEY)).isNull();
+  }
+
+  @Test
+  public void getIfPresentObjectNonStringTest() throws Exception {
+    assertThat(cache.getIfPresent(new Object())).isNull();
+  }
+
+  @Test
+  public void getIfPresentInvalidKeyTest() throws Exception {
+    loadKeyToCacheDir(INVALID_KEY);
+    Path path = websessionDir.resolve(INVALID_KEY);
+    assertThat(cache.getIfPresent(path)).isNull();
+  }
+
+  @Test
+  public void getIfPresentTest() throws Exception {
+    loadKeyToCacheDir(EXISTING_KEY);
+    assertThat(cache.getIfPresent(EXISTING_KEY)).isNotNull();
+  }
+
+  @Test
+  public void getAllPresentTest() throws Exception {
+    loadKeyToCacheDir(EMPTY_KEY);
+    loadKeyToCacheDir(INVALID_KEY);
+    loadKeyToCacheDir(EXISTING_KEY);
+    List<String> keys =
+        Arrays.asList(new String[] {EMPTY_KEY, EXISTING_KEY});
+    assertThat(cache.getAllPresent(keys).size()).isEqualTo(1);
+    assertThat(cache.getAllPresent(keys)).containsKey(EXISTING_KEY);
+  }
+
+  @Test
+  public void getTest() throws Exception {
+    class ValueLoader implements Callable<Val> {
+      @Override
+      public Val call() throws Exception {
+        return null;
+      }
+    }
+    assertThat(cache.get(EXISTING_KEY, new ValueLoader())).isNull();
+
+    loadKeyToCacheDir(EXISTING_KEY);
+    assertThat(cache.get(EXISTING_KEY, new ValueLoader())).isNotNull();
+  }
+
+  @Test(expected = ExecutionException.class)
+  public void getTestCallableThrowsException() throws Exception {
+    class ValueLoader implements Callable<Val> {
+      @Override
+      public Val call() throws Exception {
+        throw new Exception();
+      }
+    }
+    assertThat(cache.get(EXISTING_KEY, new ValueLoader())).isNull();
+  }
+
+  @Test
+  public void invalidateAllCollectionTest() throws Exception {
+    int numberOfKeys = 15;
+    List<String> keys = loadKeysToCacheDir(numberOfKeys);
+    assertThat(cache.size()).isEqualTo(numberOfKeys);
+    assertThat(isDirEmpty(websessionDir)).isFalse();
+
+    cache.invalidateAll(keys);
+    assertThat(cache.size()).isEqualTo(0);
+    assertThat(isDirEmpty(websessionDir)).isTrue();
+  }
+
+  @Test
+  public void invalidateAllTest() throws Exception {
+    int numberOfKeys = 5;
+    loadKeysToCacheDir(numberOfKeys);
+    assertThat(cache.size()).isEqualTo(numberOfKeys);
+    assertThat(isDirEmpty(websessionDir)).isFalse();
+
+    cache.invalidateAll();
+    assertThat(cache.size()).isEqualTo(0);
+    assertThat(isDirEmpty(websessionDir)).isTrue();
+  }
+
+  @Test
+  public void invalidateTest() throws Exception {
+    Path fileToDelete = Files.createFile(websessionDir.resolve(EXISTING_KEY));
+    assertThat(Files.exists(fileToDelete)).isTrue();
+    cache.invalidate(EXISTING_KEY);
+    assertThat(Files.exists(fileToDelete)).isFalse();
+  }
+
+  @Test
+  public void invalidateTestObjectNotString() throws Exception {
+    loadKeyToCacheDir(EXISTING_KEY);
+    assertThat(cache.size()).isEqualTo(1);
+    cache.invalidate(new Object());
+    assertThat(cache.size()).isEqualTo(1);
+  }
+
+  @Test
+  public void putTest() throws Exception {
+    loadKeyToCacheDir(EXISTING_KEY);
+    Val val = cache.getIfPresent(EXISTING_KEY);
+    String newKey = "abcde12345";
+    cache.put(newKey, val);
+    assertThat(cache.getIfPresent(newKey)).isNotNull();
+  }
+
+  @Test
+  public void putAllTest() throws Exception {
+    loadKeyToCacheDir(EXISTING_KEY);
+    Val val = cache.getIfPresent(EXISTING_KEY);
+    String newKey = "abcde12345";
+    Map<String, Val> sessions = ImmutableMap.of(newKey, val);
+    cache.putAll(sessions);
+    assertThat(cache.asMap()).containsKey(newKey);
+  }
+
+  @Test
+  public void putWithErrorsTest() throws Exception {
+    loadKeyToCacheDir(EXISTING_KEY);
+    Val val = cache.getIfPresent(EXISTING_KEY);
+    String newKey = "abcde12345";
+    tempFolder.delete();
+    cache.put(newKey, val);
+    assertThat(cache.getIfPresent(newKey)).isNull();
+  }
+
+  @Test
+  public void sizeTest() throws Exception {
+    int numberOfKeys = 10;
+    loadKeysToCacheDir(numberOfKeys);
+    assertThat(cache.size()).isEqualTo(numberOfKeys);
+  }
+
+  @Test
+  public void statTest() throws Exception {
+    assertThat(cache.stats()).isNull();
+  }
+
+  private List<String> loadKeysToCacheDir(int number) throws IOException {
+    List<String> keys = new ArrayList<>();
+    for (int i = 0; i < number; i++) {
+      Path tmp = Files.createTempFile(websessionDir, "cache", null);
+      keys.add(tmp.getFileName().toString());
+    }
+    return keys;
+  }
+
+  private boolean isDirEmpty(final Path dir) throws IOException {
+    try (DirectoryStream<Path> dirStream = Files.newDirectoryStream(dir)) {
+      return !dirStream.iterator().hasNext();
+    }
+  }
+
+  private Path loadKeyToCacheDir(String key) throws IOException {
+    if(key.equals(EMPTY_KEY)){
+      return Files.createFile(websessionDir.resolve(EMPTY_KEY));
+    }
+    InputStream in = loadFile(key);
+    Path target = websessionDir.resolve(key);
+    Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING);
+    return target;
+  }
+
+  private InputStream loadFile(String file) {
+    return this.getClass().getResourceAsStream("/" + file);
+  }
+}
diff --git a/src/test/java/com/ericsson/gerrit/plugins/highavailability/websession/file/FileBasedWebsessionModuleTest.java b/src/test/java/com/ericsson/gerrit/plugins/highavailability/websession/file/FileBasedWebsessionModuleTest.java
new file mode 100644
index 0000000..669262c
--- /dev/null
+++ b/src/test/java/com/ericsson/gerrit/plugins/highavailability/websession/file/FileBasedWebsessionModuleTest.java
@@ -0,0 +1,64 @@
+// Copyright (C) 2017 Ericsson
+//
+// 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 com.ericsson.gerrit.plugins.highavailability.websession.file;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.concurrent.TimeUnit.HOURS;
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.mockito.Mockito.when;
+
+import com.google.gerrit.server.config.PluginConfig;
+import com.google.gerrit.server.config.PluginConfigFactory;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+
+@RunWith(MockitoJUnitRunner.class)
+public class FileBasedWebsessionModuleTest {
+
+  private static String SOME_PLUGIN_NAME = "somePluginName";
+
+  @Mock
+  private PluginConfig configMock;
+  @Mock
+  private PluginConfigFactory pluginCfgFactoryMock;
+
+  private FileBasedWebsessionModule module;
+
+  @Before
+  public void setUp() throws Exception {
+    when(pluginCfgFactoryMock.getFromGerritConfig(SOME_PLUGIN_NAME, true))
+        .thenReturn(configMock);
+    module = new FileBasedWebsessionModule();
+  }
+
+  @Test
+  public void testDetCleanupIntervalDefaultValue() {
+    assertThat(
+        module.getCleanupInterval(pluginCfgFactoryMock, SOME_PLUGIN_NAME))
+            .isEqualTo(HOURS.toMillis(24));
+  }
+
+  @Test
+  public void testDetCleanupIntervalConfiguredValue() {
+    when(configMock.getString("cleanupInterval")).thenReturn("30 seconds");
+    assertThat(
+        module.getCleanupInterval(pluginCfgFactoryMock, SOME_PLUGIN_NAME))
+            .isEqualTo(SECONDS.toMillis(30));
+  }
+}
diff --git a/src/test/resources/aOFdpHriBM6dN055M13PjDdTZagl5r5aSG b/src/test/resources/aOFdpHriBM6dN055M13PjDdTZagl5r5aSG
new file mode 100644
index 0000000..96c6933
--- /dev/null
+++ b/src/test/resources/aOFdpHriBM6dN055M13PjDdTZagl5r5aSG
Binary files differ
diff --git a/src/test/resources/aSceprtBc02YaMY573T5jfW64ZudJfPbDq b/src/test/resources/aSceprtBc02YaMY573T5jfW64ZudJfPbDq
new file mode 100644
index 0000000..1a40c64
--- /dev/null
+++ b/src/test/resources/aSceprtBc02YaMY573T5jfW64ZudJfPbDq
Binary files differ