/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements. See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You 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.apache.activemq.artemis.core.security.jaas;

import javax.security.auth.Subject;
import javax.security.auth.callback.CallbackHandler;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.Properties;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

import org.apache.activemq.artemis.spi.core.security.jaas.JaasCallbackHandler;
import org.apache.activemq.artemis.spi.core.security.jaas.PropertiesLoader;
import org.apache.activemq.artemis.spi.core.security.jaas.PropertiesLoginModule;
import org.apache.activemq.artemis.utils.ActiveMQThreadFactory;
import org.junit.After;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ErrorCollector;
import org.junit.rules.TemporaryFolder;
import org.junit.rules.TestName;

import static org.junit.Assert.assertTrue;

public class PropertiesLoginModuleRaceConditionTest {

   private static final String ROLES_FILE = "roles.properties";
   private static final String USERS_FILE = "users.properties";
   private static final String USERNAME = "first";
   private static final String PASSWORD = "secret";

   @Rule
   public final ErrorCollector e = new ErrorCollector();

   @Rule
   public final TemporaryFolder temp = new TemporaryFolder();

   @Rule
   public final TestName name = new TestName();

   private Map<String, String> options;
   private BlockingQueue<Exception> errors;
   private ExecutorService pool;
   private CallbackHandler callback;

   private static class LoginTester implements Runnable {

      private final CountDownLatch finished;
      private final BlockingQueue<Exception> errors;
      private final Map<String, String> options;
      private final CountDownLatch start;
      private final CallbackHandler callback;

      LoginTester(CountDownLatch start,
                  CountDownLatch finished,
                  BlockingQueue<Exception> errors,
                  Map<String, String> options,
                  CallbackHandler callbackHandler) {
         this.finished = finished;
         this.errors = errors;
         this.options = options;
         this.start = start;
         this.callback = callbackHandler;
      }

      @Override
      public void run() {
         try {
            start.await();

            Subject subject = new Subject();
            PropertiesLoginModule module = new PropertiesLoginModule();
            module.initialize(subject, callback, new HashMap<String, Object>(), options);
            module.login();
            module.commit();
         } catch (Exception e) {
            errors.offer(e);
         } finally {
            finished.countDown();
         }
      }
   }

   @Before
   public void before() throws FileNotFoundException, IOException {
      createUsers();
      createGroups();

      options = new HashMap<>();
      options.put("reload", "true"); // Used to simplify reproduction of the
      // race condition
      options.put("org.apache.activemq.jaas.properties.user", USERS_FILE);
      options.put("org.apache.activemq.jaas.properties.role", ROLES_FILE);
      options.put("baseDir", temp.getRoot().getAbsolutePath());

      errors = new ArrayBlockingQueue<>(processorCount());
      pool = Executors.newFixedThreadPool(processorCount(), ActiveMQThreadFactory.defaultThreadFactory());
      callback = new JaasCallbackHandler(USERNAME, PASSWORD, null);
   }

   @After
   public void after() throws InterruptedException {
      pool.shutdown();
      assertTrue(pool.awaitTermination(500, TimeUnit.SECONDS));
      PropertiesLoader.resetUsersAndGroupsCache();
   }

   @Test
   public void raceConditionInUsersAndGroupsLoading() throws InterruptedException, FileNotFoundException, IOException {

      // Brute force approach to increase the likelihood of the race condition occurring
      for (int i = 0; i < 25000; i++) {
         final CountDownLatch start = new CountDownLatch(1);
         final CountDownLatch finished = new CountDownLatch(processorCount());
         prepareLoginThreads(start, finished);

         // Releases every login thread simultaneously to increase our chances of
         // encountering the race condition
         start.countDown();

         finished.await();
         if (isRaceConditionDetected()) {
            e.addError(new AssertionError("At least one race condition in PropertiesLoginModule " + "has been encountered. Please examine the " + "following stack traces for more details:"));
            for (Exception exception : errors) {
               e.addError(exception);
            }
            return;
         }
      }
   }

   private boolean isRaceConditionDetected() {
      return errors.size() > 0;
   }

   private void prepareLoginThreads(final CountDownLatch start, final CountDownLatch finished) {
      for (int processor = 1; processor <= processorCount() * 2; processor++) {
         pool.submit(new LoginTester(start, finished, errors, options, callback));
      }
   }

   private int processorCount() {
      return Runtime.getRuntime().availableProcessors();
   }

   private void store(Properties from, File to) throws FileNotFoundException, IOException {
      try (FileOutputStream output = new FileOutputStream(to)) {
         from.store(output, "Generated by " + name.getMethodName());
      }
   }

   private void createGroups() throws FileNotFoundException, IOException {
      Properties groups = new Properties();
      for (int i = 0; i < 100; i++) {
         groups.put("group" + i, "first,second,third");
      }
      store(groups, temp.newFile(ROLES_FILE));
   }

   private void createUsers() throws FileNotFoundException, IOException {
      Properties users = new Properties();
      users.put(USERNAME, PASSWORD);
      users.put("second", PASSWORD);
      users.put("third", PASSWORD);
      store(users, temp.newFile(USERS_FILE));
   }
}
