/*
 * 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.jackrabbit.oak.security.user;

import java.lang.reflect.Field;
import java.util.Collections;
import java.util.List;
import javax.jcr.RepositoryException;
import javax.jcr.nodetype.ConstraintViolationException;

import org.apache.jackrabbit.guava.common.collect.ImmutableList;
import org.apache.jackrabbit.guava.common.collect.ImmutableMap;
import org.apache.jackrabbit.guava.common.collect.Iterables;
import org.apache.jackrabbit.api.security.user.User;
import org.apache.jackrabbit.oak.AbstractSecurityTest;
import org.apache.jackrabbit.oak.api.Tree;
import org.apache.jackrabbit.oak.spi.security.ConfigurationParameters;
import org.apache.jackrabbit.oak.spi.security.user.UserConfiguration;
import org.apache.jackrabbit.oak.spi.security.user.UserConstants;
import org.apache.jackrabbit.oak.spi.security.user.util.PasswordUtil;
import org.apache.jackrabbit.oak.plugins.tree.TreeUtil;
import org.jetbrains.annotations.NotNull;
import org.junit.Test;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotSame;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;

/**
 * @see <a href="https://issues.apache.org/jira/browse/OAK-2445">OAK-2445</a>
 */
public class PasswordHistoryTest extends AbstractSecurityTest implements UserConstants {

    private static final String[] PASSWORDS = {
            "abc", "def", "ghi", "jkl", "mno", "pqr", "stu", "vwx", "yz0", "123", "456", "789"
    };

    private static final ConfigurationParameters CONFIG = ConfigurationParameters.of(PARAM_PASSWORD_HISTORY_SIZE, 10);

    @Override
    protected ConfigurationParameters getSecurityConfigParameters() {
        return ConfigurationParameters.of(ImmutableMap.of(UserConfiguration.NAME, CONFIG));
    }

    @NotNull
    private List<String> getHistory(@NotNull User user) throws RepositoryException {
        Iterable<String> history = TreeUtil.getStrings(root.getTree(user.getPath()).getChild(REP_PWD), REP_PWD_HISTORY);
        return (history == null) ? Collections.emptyList() : ImmutableList.copyOf(history).reverse();
    }

    /**
     * Use reflection to access the private fields stored in the PasswordHistory
     */
    private static Integer getMaxSize(@NotNull PasswordHistory history) throws Exception {
        Field maxSize = history.getClass().getDeclaredField("maxSize");
        maxSize.setAccessible(true);
        return (Integer) maxSize.get(history);
    }

    /**
     * Use reflection to access the private fields stored in the PasswordHistory
     */
    private static boolean isEnabled(@NotNull PasswordHistory history) throws Exception {
        Field isEnabled = history.getClass().getDeclaredField("isEnabled");
        isEnabled.setAccessible(true);
        return (Boolean) isEnabled.get(history);
    }

    @Test
    public void testNoPwdTreeOnUserCreation() throws Exception {
        User user = getTestUser();
        assertFalse(root.getTree(user.getPath()).hasChild(REP_PWD));
    }

    @Test
    public void testHistoryEmptyOnUserCreationWithPassword() throws Exception {
        User user = getTestUser(); // the user is created with a password set

        // the rep:pwd child must not exist. without the rep:pwd child no password history can exist.
        assertFalse(root.getTree(user.getPath()).hasChild(REP_PWD));
    }

    @Test
    public void testHistoryWithSinglePasswordChange() throws Exception {
        // the user must be able to change the password
        User user = getTestUser();
        String oldPassword = TreeUtil.getString(root.getTree(user.getPath()), REP_PASSWORD);
        user.changePassword("newPwd");
        root.commit();

        // after changing the password, 1 password history entry should be present and the
        // recorded password should be equal to the user's initial password
        // however, the user's current password must not match the old password.
        assertTrue(root.getTree(user.getPath()).hasChild(REP_PWD));

        Tree pwTree = root.getTree(user.getPath()).getChild(REP_PWD);
        assertTrue(pwTree.hasProperty(REP_PWD_HISTORY));

        List<String> history = getHistory(user);
        assertEquals(1, history.size());
        assertEquals(oldPassword, history.iterator().next());

        String currentPw = TreeUtil.getString(root.getTree(user.getPath()), REP_PASSWORD);
        assertNotSame(currentPw, oldPassword);
    }

    @Test
    public void testHistoryMaxSize() throws Exception {
        User user = getTestUser();

        // we're changing the password 12 times, history max is 10
        for (String pw : PASSWORDS) {
            user.changePassword(pw);
            root.commit();
        }

        assertEquals(10, getHistory(user).size());
    }

    @Test
    public void testHistoryOrder() throws Exception {
        User user = getTestUser();

        // we're changing the password 12 times, history max is 10
        for (String pw : PASSWORDS) {
            user.changePassword(pw);
        }

        // we skip the first entry in the password list as it was shifted out
        // due to max history size = 10.
        int i = 1;
        for (String pwHash : getHistory(user)) {
            assertTrue(PasswordUtil.isSame(pwHash, PASSWORDS[i++]));
        }
    }

    @Test
    public void testRepeatedPwAfterHistorySizeReached() throws Exception {
        User user = getTestUser();
        for (String pw : PASSWORDS) {
            user.changePassword(pw);
        }

        // changing pw back to the original value (as used for creation) must succeed
        user.changePassword(user.getID());
        // now, using all old passwords must also succeed as they get shifted out
        for (String pw : PASSWORDS) {
            user.changePassword(pw);
        }
    }

    @Test(expected = ConstraintViolationException.class)
    public void testHistoryViolationAtFirstChange() throws Exception {
        User user = getTestUser();
        user.changePassword(user.getID());
    }

    @Test(expected = ConstraintViolationException.class)
    public void testHistoryViolation() throws Exception {
        User user = getTestUser();
        user.changePassword("abc");
        user.changePassword("def");
        user.changePassword("abc");
    }

    @Test
    public void testNoHistoryUpdateOnViolation() throws Exception {
        User user = getTestUser();
        try {
            user.changePassword("abc");
            user.changePassword("def");
            user.changePassword("abc");
            fail("history violation not detected");
        } catch (ConstraintViolationException e) {
            String[] expected = new String[] {user.getID(), "abc"};
            int i = 0;
            for (String pwHash : getHistory(user)) {
                assertTrue(PasswordUtil.isSame(pwHash, expected[i++]));
            }
        }
    }

    @Test
    public void testEnabledPasswordHistory() throws Exception {
        PasswordHistory history = new PasswordHistory(CONFIG);
        assertTrue(isEnabled(history));
        assertEquals(10, getMaxSize(history).longValue());
    }

    @Test
    public void testHistoryUpperLimit() throws Exception {
        PasswordHistory history = new PasswordHistory(ConfigurationParameters.of(PARAM_PASSWORD_HISTORY_SIZE, Integer.MAX_VALUE));

        assertTrue(isEnabled(history));
        assertEquals(1000, getMaxSize(history).longValue());
    }

    @Test
    public void testDisabledPasswordHistory() throws Exception {
        User user = getTestUser();
        Tree userTree = root.getTree(user.getPath());

        List<ConfigurationParameters> configs = ImmutableList.of(
                ConfigurationParameters.EMPTY,
                ConfigurationParameters.of(PARAM_PASSWORD_HISTORY_SIZE, PASSWORD_HISTORY_DISABLED_SIZE),
                ConfigurationParameters.of(PARAM_PASSWORD_HISTORY_SIZE, -1),
                ConfigurationParameters.of(PARAM_PASSWORD_HISTORY_SIZE, Integer.MIN_VALUE)
        );

        for (ConfigurationParameters config : configs) {
            PasswordHistory disabledHistory = new PasswordHistory(config);

            assertFalse(isEnabled(disabledHistory));
            assertFalse(disabledHistory.updatePasswordHistory(userTree, user.getID()));
        }
    }

    @Test(expected = ConstraintViolationException.class)
    public void testCheckPasswordHistory() throws Exception {
        Tree userTree = root.getTree(getTestUser().getPath());

        PasswordHistory history = new PasswordHistory(CONFIG);
        assertTrue(isEnabled(history));
        assertEquals(10, getMaxSize(history).longValue());

        history.updatePasswordHistory(userTree, getTestUser().getID());
    }

    @Test
    public void testConfigurationChange() throws Exception {
        User user = getTestUser();
        Tree userTree = root.getTree(user.getPath());

        PasswordHistory history = new PasswordHistory(CONFIG);
        for (String pw : PASSWORDS) {
            assertTrue(history.updatePasswordHistory(userTree, pw));
        }
        assertEquals(10, getHistory(user).size());

        // change configuration to a smaller size
        history = new PasswordHistory(ConfigurationParameters.of(PARAM_PASSWORD_HISTORY_SIZE, 5));
        List<String> oldPwds = getHistory(user);
        assertEquals(10, oldPwds.size());

        // only the configured max-size number of entries in the history must be
        // checked. additional entries in the history must be ignored
        Iterables.skip(oldPwds, 6);
        history.updatePasswordHistory(userTree, oldPwds.iterator().next());

        // after chaning the pwd again however the rep:pwdHistory property must
        // only contain the max-size number of passwords
        assertEquals(5, getHistory(user).size());

        history = new PasswordHistory(CONFIG);
        history.updatePasswordHistory(userTree, "newPwd");
        assertEquals(6, getHistory(user).size());
    }

    @Test
    public void testEnableDisable() throws Exception {
        User user = getTestUser();
        Tree userTree = root.getTree(user.getPath());

        PasswordHistory history = new PasswordHistory(CONFIG);
        for (String pw : PASSWORDS) {
            assertTrue(history.updatePasswordHistory(userTree, pw));
        }
        assertEquals(10, getHistory(user).size());

        // disable the password history : changing the pw now must not
        // modify the rep:pwdHistory property.
        history = new PasswordHistory(ConfigurationParameters.EMPTY);
        history.updatePasswordHistory(userTree, PASSWORDS[8]);

        assertEquals(10, getHistory(user).size());
    }

    @Test
    public void testSingleTypeHistoryProperty() throws Exception {
        Tree userTree = root.getTree(getTestUser().getPath());
        Tree pwdNode = TreeUtil.getOrAddChild(userTree, REP_PWD, NT_REP_PASSWORD);

        pwdNode.setProperty(REP_PWD_HISTORY, "singleValuedProperty");
        assertFalse(pwdNode.getProperty(REP_PWD_HISTORY).isArray());
        assertFalse(pwdNode.getProperty(REP_PWD_HISTORY).getType().isArray());

        PasswordHistory history = new PasswordHistory(CONFIG);
        assertTrue(history.updatePasswordHistory(userTree, "anyOtherPassword"));

        assertTrue(pwdNode.getProperty(REP_PWD_HISTORY).isArray());
        assertTrue(pwdNode.getProperty(REP_PWD_HISTORY).getType().isArray());
    }

    @Test
    public void testUpdateMissingPwHash() throws Exception {
        User u = getUserManager(root).createUser("uid", null);
        PasswordHistory ph = new PasswordHistory(ConfigurationParameters.of(UserConstants.PARAM_PASSWORD_HISTORY_SIZE, UserConstants.PASSWORD_HISTORY_DISABLED_SIZE+1));
        assertFalse(ph.updatePasswordHistory(root.getTree(u.getPath()), "pw"));
    }
}
