/*
 * 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.spi.security.authentication.external.impl.jmx;

import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import javax.jcr.RepositoryException;
import javax.jcr.ValueFactory;

import org.apache.jackrabbit.guava.common.collect.ImmutableMap;
import org.apache.jackrabbit.guava.common.collect.ImmutableSet;
import org.apache.jackrabbit.api.security.user.Authorizable;
import org.apache.jackrabbit.api.security.user.Group;
import org.apache.jackrabbit.api.security.user.User;
import org.apache.jackrabbit.api.security.user.UserManager;
import org.apache.jackrabbit.oak.spi.security.authentication.external.ExternalGroup;
import org.apache.jackrabbit.oak.spi.security.authentication.external.ExternalIdentity;
import org.apache.jackrabbit.oak.spi.security.authentication.external.ExternalIdentityException;
import org.apache.jackrabbit.oak.spi.security.authentication.external.ExternalIdentityProvider;
import org.apache.jackrabbit.oak.spi.security.authentication.external.ExternalIdentityProviderManager;
import org.apache.jackrabbit.oak.spi.security.authentication.external.ExternalIdentityRef;
import org.apache.jackrabbit.oak.spi.security.authentication.external.ExternalUser;
import org.apache.jackrabbit.oak.spi.security.authentication.external.SyncContext;
import org.apache.jackrabbit.oak.spi.security.authentication.external.SyncException;
import org.apache.jackrabbit.oak.spi.security.authentication.external.SyncManager;
import org.apache.jackrabbit.oak.spi.security.authentication.external.SyncResult;
import org.apache.jackrabbit.oak.spi.security.authentication.external.SyncedIdentity;
import org.apache.jackrabbit.oak.spi.security.authentication.external.TestIdentityProvider;
import org.apache.jackrabbit.oak.spi.security.authentication.external.basic.DefaultSyncContext;
import org.apache.jackrabbit.oak.spi.security.authentication.external.basic.DefaultSyncResultImpl;
import org.apache.jackrabbit.oak.spi.security.authentication.external.impl.DefaultSyncHandler;
import org.apache.jackrabbit.oak.spi.security.user.UserConstants;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.junit.Before;
import org.junit.Test;

import static org.apache.jackrabbit.oak.spi.security.authentication.external.TestIdentityProvider.ID_TEST_USER;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;

public class SyncMBeanImplTest extends AbstractJmxTest {

    private static final String SYNC_NAME = "testSyncName";

    private SyncMBeanImpl syncMBean;

    private SyncManager syncMgr;
    private ExternalIdentityProviderManager idpMgr;

    @Before
    public void before() throws Exception {
        super.before();

        syncMgr = name -> {
            if (SYNC_NAME.equals(name)) {
                return new DefaultSyncHandler(syncConfig);
            } else if (ThrowingSyncHandler.NAME.equals(name)) {
                return new ThrowingSyncHandler(false);
            } else if (ThrowingSyncHandler.NAME_ALLOWS_IDENTITY_LISTING.equals(name)) {
                return new ThrowingSyncHandler(true);
            } else {
                return null;
            }
        };
        idpMgr = name -> {
            if (name.equals(idp.getName())) {
                return idp;
            } else {
                return null;
            }
        };

        syncMBean = createSyncMBeanImpl(SYNC_NAME, idp.getName());
    }

    private Map<String, String> getExpectedUserResult(String expectedOp, boolean includeGroups) throws ExternalIdentityException {
        Map<String, String> expected = new HashMap<>();
        Iterator<ExternalUser> it = idp.listUsers();
        while (it.hasNext()) {
            ExternalUser eu = it.next();
            expected.put(eu.getId(), expectedOp);
            if (includeGroups) {
                for (ExternalIdentityRef ref : eu.getDeclaredGroups()) {
                    expected.put(ref.getId(), expectedOp);
                }
            }
        }
        return expected;
    }

    private SyncMBeanImpl createSyncMBeanImpl(@NotNull String syncHandlerName, @NotNull String idpName) {
        return new SyncMBeanImpl(getContentRepository(), getSecurityProvider(), syncMgr, syncHandlerName, idpMgr, idpName);
    }

    private SyncMBeanImpl createThrowingSyncMBean(boolean allowListIdentities) {
        String name = (allowListIdentities) ? ThrowingSyncHandler.NAME_ALLOWS_IDENTITY_LISTING : ThrowingSyncHandler.NAME;
        return new SyncMBeanImpl(getContentRepository(), getSecurityProvider(), syncMgr, name, idpMgr, idp.getName());
    }

    @Test
    public void testGetSyncHandlerName() {
        assertEquals(SYNC_NAME, syncMBean.getSyncHandlerName());
    }

    @Test
    public void testInvalidSyncHandlerName() {
        SyncMBeanImpl syncMBean = createSyncMBeanImpl("invalid", idp.getName());
        assertEquals("invalid", syncMBean.getSyncHandlerName());

        // calling any sync-operation must fail due to the invalid configuration
        try {
            syncMBean.syncAllExternalUsers();
            fail("syncAllExternalUsers with invalid SyncHandlerName must fail");
        } catch (IllegalArgumentException e) {
            //success
        }
    }

    @Test
    public void testGetIDPName() {
        assertEquals(idp.getName(), syncMBean.getIDPName());
    }

    @Test
    public void testInvalidIDPName() {
        SyncMBeanImpl syncMBean = createSyncMBeanImpl(SYNC_NAME, "invalid");
        assertEquals("invalid", syncMBean.getIDPName());

        // calling any sync-operation must fail due to the invalid configuration
        try {
            syncMBean.syncAllExternalUsers();
            fail("syncAllExternalUsers with invalid IDP name must fail");
        } catch (IllegalArgumentException e) {
            //success
        }
    }

    /**
     * test users have never been synced before => result must be NSA
     */
    @Test
    public void testSyncUsersBefore() {
        String[] userIds = new String[] {ID_TEST_USER, TestIdentityProvider.ID_SECOND_USER};

        String[] result = syncMBean.syncUsers(userIds, false);
        assertResultMessages(result, ImmutableMap.of(ID_TEST_USER, "nsa", TestIdentityProvider.ID_SECOND_USER, "nsa"));

        result = syncMBean.syncUsers(userIds, true);
        assertResultMessages(result, ImmutableMap.of(ID_TEST_USER, "nsa", TestIdentityProvider.ID_SECOND_USER, "nsa"));
    }

    @Test
    public void testSyncUsers() throws Exception {
        sync(idp, ID_TEST_USER, false);

        String[] userIds = new String[]{ID_TEST_USER, TestIdentityProvider.ID_SECOND_USER};
        String[] result = syncMBean.syncUsers(userIds, false);
        assertResultMessages(result, ImmutableMap.of(ID_TEST_USER, "upd", TestIdentityProvider.ID_SECOND_USER, "nsa"));

        result = syncMBean.syncUsers(userIds, true);
        assertResultMessages(result, ImmutableMap.of(ID_TEST_USER, "upd", TestIdentityProvider.ID_SECOND_USER, "nsa"));
    }

    @Test
    public void testSyncUsersAlwaysForcesSync() throws Exception {
        sync(idp, ID_TEST_USER, false);

        String[] userIds = new String[]{ID_TEST_USER, TestIdentityProvider.ID_SECOND_USER};
        syncConfig.user().setExpirationTime(Long.MAX_VALUE);

        String[]result = syncMBean.syncUsers(userIds, false);
        assertResultMessages(result, ImmutableMap.of(ID_TEST_USER, "upd", TestIdentityProvider.ID_SECOND_USER, "nsa"));
    }

    @Test
    public void testSyncGroups() throws Exception {
        sync(idp, "a", true);

        Map<String, String> expected = ImmutableMap.of("a", "upd");
        syncConfig.group().setExpirationTime(Long.MAX_VALUE);

        // force group sync is true by default => exp time is ignored
        String[] result = syncMBean.syncUsers(expected.keySet().toArray(new String[0]), false);
        assertResultMessages(result, expected);
    }

    @Test
    public void testSyncUsersPurge() throws Exception {
        sync(new TestIdentityProvider.TestUser("thirdUser", idp.getName()), idp);
        sync(new TestIdentityProvider.TestGroup("gr", idp.getName()), idp);

        UserManager userManager = getUserManager();
        Authorizable[] authorizables = new Authorizable[] {
                userManager.getAuthorizable("thirdUser"),
                userManager.getAuthorizable("gr")
        };

        for (Authorizable a : authorizables) {
            String[] ids = new String[]{a.getID()};
            String[] result = syncMBean.syncUsers(ids, false);
            assertResultMessages(result, a.getID(), "mis");
            assertNotNull(userManager.getAuthorizable(a.getID()));

            result = syncMBean.syncUsers(ids, true);
            assertResultMessages(result, a.getID(), "del");
            assertNull(getUserManager().getAuthorizable(a.getID()));
        }
    }

    @Test
    public void testSyncUsersNonExisting() {
        String[] result = syncMBean.syncUsers(new String[] {"nonExisting"}, false);
        assertResultMessages(result, "nonExisting", "nsa");
    }

    @Test
    public void testSyncUsersLocal() {
        String[] result = syncMBean.syncUsers(new String[] {UserConstants.DEFAULT_ANONYMOUS_ID}, false);
        assertResultMessages(result, UserConstants.DEFAULT_ANONYMOUS_ID, "for");
    }

    @Test
    public void testSyncUsersLocalPurge() throws Exception {
        String[] result = syncMBean.syncUsers(new String[] {UserConstants.DEFAULT_ANONYMOUS_ID}, true);
        assertResultMessages(result, UserConstants.DEFAULT_ANONYMOUS_ID, "for");

        assertNotNull(getUserManager().getAuthorizable(UserConstants.DEFAULT_ANONYMOUS_ID));
    }

    @Test
    public void testSyncUsersForeign() throws Exception {
        // sync user from foreign IDP into the repository
        SyncResult res = sync(foreignIDP, ID_TEST_USER, false);
        assertNotNull(getUserManager().getAuthorizable(ID_TEST_USER));
        assertEquals(foreignIDP.getUser(ID_TEST_USER).getExternalId(), res.getIdentity().getExternalIdRef());

        // syncUsers with testIDP must detect the foreign status
        String[] result = syncMBean.syncUsers(new String[]{ID_TEST_USER}, false);
        assertResultMessages(result, ID_TEST_USER, "for");
        assertNotNull(getUserManager().getAuthorizable(ID_TEST_USER));

        // same expected with 'purge' set to true
        result = syncMBean.syncUsers(new String[] {ID_TEST_USER}, true);
        assertResultMessages(result, ID_TEST_USER, "for");
        assertNotNull(getUserManager().getAuthorizable(ID_TEST_USER));
    }

    @Test
    public void testSyncGroupsForeign() throws Exception {
        // sync user from foreign IDP into the repository
        SyncResult res = sync(foreignIDP, "a", true);
        assertNotNull(getUserManager().getAuthorizable("a"));
        assertEquals(foreignIDP.getGroup("a").getExternalId(), res.getIdentity().getExternalIdRef());

        // syncUsers with testIDP must detect the foreign status
        String[] result = syncMBean.syncUsers(new String[]{"a"}, false);
        assertResultMessages(result, "a", "for");
        assertNotNull(getUserManager().getAuthorizable("a"));

        // same expected with 'purge' set to true
        result = syncMBean.syncUsers(new String[] {"a"}, true);
        assertResultMessages(result, "a", "for");
        assertNotNull(getUserManager().getAuthorizable("a"));
    }

    /**
     * @see <a href="https://issues.apache.org/jira/browse/OAK-4360">OAK-4360</a>
     */
    @Test
    public void testSyncUserException() throws Exception {
        User u = getUserManager().createUser(TestIdentityProvider.ID_EXCEPTION, null);
        u.setProperty(DefaultSyncContext.REP_EXTERNAL_ID, getValueFactory().createValue(new ExternalIdentityRef(TestIdentityProvider.ID_EXCEPTION, idp.getName()).getString()));
        root.commit();

        String[] result = syncMBean.syncUsers(new String[]{TestIdentityProvider.ID_EXCEPTION}, false);
        assertResultMessages(result, TestIdentityProvider.ID_EXCEPTION, "ERR");
    }

    @Test
    public void testSyncUserThrowingHandler() throws Exception {
        sync(idp, ID_TEST_USER, false);

        String[] result = createThrowingSyncMBean(false).syncUsers(new String[]{ID_TEST_USER}, false);
        assertResultMessages(result, ID_TEST_USER, "ERR");
    }

    @Test
    public void testInitialSyncExternalUsers() throws Exception {
        ExternalUser externalUser = idp.getUser(ID_TEST_USER);
        String[] externalId = new String[] {externalUser.getExternalId().getString()};

        String[] result = syncMBean.syncExternalUsers(externalId);
        assertResultMessages(result, ID_TEST_USER, "add");

        UserManager userManager = getUserManager();
        User testUser = userManager.getAuthorizable(externalUser.getId(), User.class);
        assertNotNull(testUser);

        for (ExternalIdentityRef groupRef : externalUser.getDeclaredGroups()) {
            assertNotNull(userManager.getAuthorizable(groupRef.getId()));
        }
    }

    @Test
    public void testInitialSyncExternalUsersNoNesting() throws Exception {
        syncConfig.user().setMembershipNestingDepth(-1);

        ExternalUser externalUser = idp.getUser(ID_TEST_USER);
        String[] externalId = new String[] {externalUser.getExternalId().getString()};

        String[] result = syncMBean.syncExternalUsers(externalId);
        assertResultMessages(result, ID_TEST_USER, "add");

        UserManager userManager = getUserManager();
        User testUser = userManager.getAuthorizable(externalUser.getId(), User.class);
        assertNotNull(testUser);

        for (ExternalIdentityRef groupRef : externalUser.getDeclaredGroups()) {
            assertNull(userManager.getAuthorizable(groupRef.getId()));
        }
    }

    @Test
    public void testSyncExternalUsersLastSyncedProperty() throws Exception {
        ExternalUser externalUser = idp.getUser(ID_TEST_USER);
        String[] externalId = new String[]{externalUser.getExternalId().getString()};

        syncMBean.syncExternalUsers(externalId);
        UserManager userManager = getUserManager();
        User testUser = userManager.getAuthorizable(externalUser.getId(), User.class);

        long lastSynced = testUser.getProperty(DefaultSyncContext.REP_LAST_SYNCED)[0].getLong();
        for (ExternalIdentityRef groupRef : externalUser.getDeclaredGroups()) {
            Group gr = userManager.getAuthorizable(groupRef.getId(), Group.class);
            long groupLastSynced = gr.getProperty(DefaultSyncContext.REP_LAST_SYNCED)[0].getLong();

            assertEquals(lastSynced, groupLastSynced);
        }

        while (System.currentTimeMillis() <= lastSynced) {
            // wait for system time to move
        }

        // default value for forceGroup sync is defined to be 'true' => verify result
        syncMBean.syncExternalUsers(externalId);
        userManager = getUserManager();
        testUser = userManager.getAuthorizable(externalUser.getId(), User.class);
        long lastSynced2 = testUser.getProperty(DefaultSyncContext.REP_LAST_SYNCED)[0].getLong();

        assertTrue("lastSynced: " + lastSynced + ", lastSynced2: " + lastSynced2, lastSynced < lastSynced2);
        for (ExternalIdentityRef groupRef : externalUser.getDeclaredGroups()) {
            Group gr = userManager.getAuthorizable(groupRef.getId(), Group.class);
            long groupLastSynced = gr.getProperty(DefaultSyncContext.REP_LAST_SYNCED)[0].getLong();

            assertEquals(lastSynced2, groupLastSynced);
        }
    }

    @Test
    public void testInitialSyncExternalGroup() throws Exception {
        ExternalGroup externalGroup = idp.getGroup("a");
        String[] externalId = new String[] {externalGroup.getExternalId().getString()};

        String[] result = syncMBean.syncExternalUsers(externalId);
        assertResultMessages(result, "a", "add");

        UserManager userManager = getUserManager();
        Group aGroup = userManager.getAuthorizable(externalGroup.getId(), Group.class);
        assertNotNull(aGroup);

        // membership of groups are not synced (unless imposed by user-sync with membership depth)
        for (ExternalIdentityRef groupRef : externalGroup.getDeclaredGroups()) {
            assertNull(userManager.getAuthorizable(groupRef.getId()));
        }
    }

    @Test
    public void testSyncExternalNonExisting() {
        ExternalIdentityRef ref = new ExternalIdentityRef("nonExisting", idp.getName());

        String[] result = syncMBean.syncExternalUsers(new String[]{ref.getString()});
        assertResultMessages(result, "", "nsi");
    }

    /**
     * @see <a href="https://issues.apache.org/jira/browse/OAK-4346">OAK-4346</a>
     */
    @Test
    public void testSyncExternalLocal() {
        ExternalIdentityRef ref = new ExternalIdentityRef(UserConstants.DEFAULT_ANONYMOUS_ID, null);

        String[] result = syncMBean.syncExternalUsers(new String[]{ref.getString()});
        assertResultMessages(result, UserConstants.DEFAULT_ANONYMOUS_ID, "for");
    }

    /**
     * @see <a href="https://issues.apache.org/jira/browse/OAK-4346">OAK-4346</a>
     */
    @Test
    public void testSyncExternalForeign() {
        ExternalIdentityRef ref = new ExternalIdentityRef(ID_TEST_USER, "anotherIDP");

        String[] result = syncMBean.syncExternalUsers(new String[]{ref.getString()});
        assertResultMessages(result, ID_TEST_USER, "for");

        result = syncMBean.syncExternalUsers(new String[] {ref.getString()});
        assertResultMessages(result, ID_TEST_USER, "for");
    }

    @Test
    public void testSyncExternalUserException() {
        ExternalIdentityRef ref = new ExternalIdentityRef(TestIdentityProvider.ID_EXCEPTION, idp.getName());
        String[] result = syncMBean.syncExternalUsers(new String[] {ref.getString()});
        assertResultMessages(result, TestIdentityProvider.ID_EXCEPTION, "ERR");
    }

    @Test
    public void testSyncExternalUserThrowingHandler() {
        ExternalIdentityRef ref = new ExternalIdentityRef(ID_TEST_USER, idp.getName());
        String[] result = createThrowingSyncMBean(false).syncExternalUsers(new String[]{ref.getString()});
        assertResultMessages(result, ID_TEST_USER, "ERR");
    }

    /**
     * test users have never been synced before => result must be empty
     */
    @Test
    public void testSyncAllUsersBefore() {
        String[] result = syncMBean.syncAllUsers(false);
        assertEquals(0, result.length);
    }

    @Test
    public void testSyncAllUsers() throws Exception {
        // first sync external users into the repo
        syncMBean.syncAllExternalUsers();

        // verify effect of syncAllUsers
        String[] result = syncMBean.syncAllUsers(false);

        Map<String, String> expected = getExpectedUserResult("upd", true);
        assertResultMessages(result, expected);

        UserManager userManager = getUserManager();
        for (String id : expected.keySet()) {
            ExternalIdentity ei = idp.getUser(id);
            if (ei == null) {
                ei = idp.getGroup(id);
            }
            assertSync(ei, userManager);
        }
    }

    @Test
    public void testSyncAllGroups() throws Exception {
        // first sync external users into the repo
        Map<String, String> expected = new HashMap<>();
        Iterator<ExternalGroup> grIt = idp.listGroups();
        while (grIt.hasNext()) {
            ExternalGroup eg = grIt.next();
            sync(idp, eg.getId(), true);
            expected.put(eg.getId(), "upd");
        }

        // verify effect of syncAllUsers (which in this case are groups)
        String[] result = syncMBean.syncAllUsers(false);
        assertResultMessages(result, expected);

        UserManager userManager = getUserManager();
        for (String id : expected.keySet()) {
            ExternalIdentity ei = idp.getGroup(id);
            assertSync(ei, userManager);
        }
    }

    @Test
    public void testSyncAllUsersPurgeFalse() throws Exception {
        // first sync external user|group into the repo that doesn't exist on the IDP (anymore)
        sync(new TestIdentityProvider.TestUser("thirdUser", idp.getName()), idp);
        sync(new TestIdentityProvider.TestGroup("g", idp.getName()), idp);

        // syncAll with purge = false
        String[] result = syncMBean.syncAllUsers(false);
        assertResultMessages(result, ImmutableMap.of("thirdUser", "mis", "g", "mis"));

        UserManager userManager = getUserManager();
        assertNotNull(userManager.getAuthorizable("thirdUser"));
        assertNotNull(userManager.getAuthorizable("g"));
    }

    @Test
    public void testSyncAllUsersPurgeTrue() throws Exception {
        // first sync external user|group into the repo that doesn't exist on the IDP (anymore)
        sync(new TestIdentityProvider.TestUser("thirdUser", idp.getName()), idp);
        sync(new TestIdentityProvider.TestGroup("g", idp.getName()), idp);

        // syncAll with purge = true
        String[] result = syncMBean.syncAllUsers(true);
        assertResultMessages(result, ImmutableMap.of("thirdUser", "del", "g", "del"));

        UserManager userManager = getUserManager();
        assertNull(userManager.getAuthorizable("thirdUser"));
        assertNull(userManager.getAuthorizable("g"));
    }

    @Test
    public void testSyncAllUsersForeign() throws Exception {
        // first sync external users + groups from 2 different IDPs into the repo
        // but set membership-nesting to 0
        syncConfig.user().setMembershipNestingDepth(0);
        sync(idp, ID_TEST_USER, false);
        sync(idp, "a", true);
        sync(foreignIDP, TestIdentityProvider.ID_SECOND_USER, false);
        sync(foreignIDP, "aa", true);

        // verify effect of syncAllUsers : foreign user/group must be ignored by the sync.
        String[] result = syncMBean.syncAllUsers(false);
        Map<String, String> expectedResults = ImmutableMap.of(ID_TEST_USER, "upd", "a", "upd");
        assertResultMessages(result, expectedResults);

        ExternalIdentity[] expectedIds = new ExternalIdentity[] {
                idp.getUser(ID_TEST_USER),
                foreignIDP.getUser(TestIdentityProvider.ID_SECOND_USER),
                idp.getGroup("a"),
                foreignIDP.getGroup("aa")
        };
        UserManager userManager = getUserManager();
        for (ExternalIdentity externalIdentity : expectedIds) {
            assertSync(externalIdentity, userManager);
        }
    }

    @Test
    public void testSyncAllUsersException() throws Exception {
        User u = getUserManager().createUser(TestIdentityProvider.ID_EXCEPTION, null);
        u.setProperty(DefaultSyncContext.REP_EXTERNAL_ID, getValueFactory().createValue(new ExternalIdentityRef(TestIdentityProvider.ID_EXCEPTION, idp.getName()).getString()));
        root.commit();

        String[] result = syncMBean.syncAllUsers(false);
        assertResultMessages(result, TestIdentityProvider.ID_EXCEPTION, "ERR");

        result = syncMBean.syncAllUsers(true);
        assertResultMessages(result, TestIdentityProvider.ID_EXCEPTION, "ERR");
    }

    @Test(expected = IllegalStateException.class)
    public void testSyncAllUsersThrowingHandler() {
        String[] result = createThrowingSyncMBean(false).syncAllUsers(false);
    }

    @Test
    public void testSyncAllUsersThrowingHandler2() throws Exception {
        syncMBean.syncAllExternalUsers();

        Map<String, String> expected = getExpectedUserResult("ERR", true);
        String[] result = createThrowingSyncMBean(true).syncAllUsers(false);

        assertResultMessages(result, expected);
    }

    @Test
    public void testInitialSyncAllExternalUsers() throws Exception {
        String[] result = syncMBean.syncAllExternalUsers();

        Map<String, String> expected = getExpectedUserResult("add", false);
        assertResultMessages(result, expected);

        UserManager userManager = getUserManager();
        for (String id : expected.keySet()) {
            ExternalIdentity ei = idp.getUser(id);
            if (ei == null) {
                ei = idp.getGroup(id);
            }
            assertSync(ei, userManager);
        }
    }

    @Test
    public void testSyncAllExternalUsersAgain() throws Exception {
        syncMBean.syncAllExternalUsers();

        // sync again
        String[] result = syncMBean.syncAllExternalUsers();

        // verify result
        Map<String, String> expected = getExpectedUserResult("upd", false);
        assertResultMessages(result, expected);

        UserManager userManager = getUserManager();
        for (String id : expected.keySet()) {
            ExternalIdentity ei = idp.getUser(id);
            if (ei == null) {
                ei = idp.getGroup(id);
            }
            assertSync(ei, userManager);
        }
    }

    @Test
    public void testSyncAllExternalUsersThrowingHandler() throws Exception {
        String[] result = createThrowingSyncMBean(false).syncAllExternalUsers();

        Map<String, String> expected = getExpectedUserResult("ERR", false);
        assertResultMessages(result, expected);
    }

    @Test
    public void testListOrphanedUsers() throws Exception {
        syncMBean.syncAllExternalUsers();

        String[] result = syncMBean.listOrphanedUsers();
        assertEquals(0, result.length);

        sync(new TestIdentityProvider.TestUser("thirdUser", idp.getName()), idp);
        sync(new TestIdentityProvider.TestGroup("g", idp.getName()), idp);

        result = syncMBean.listOrphanedUsers();
        assertEquals(2, result.length);
        assertEquals(ImmutableSet.of("thirdUser", "g"), ImmutableSet.copyOf(result));
    }

    @Test
    public void testListOrphanedUsersForeign() throws Exception {
        sync(foreignIDP, "a", true);
        sync(foreignIDP, ID_TEST_USER, false);

        String[] result = syncMBean.listOrphanedUsers();
        assertEquals(0, result.length);
    }

    @Test
    public void testListOrphanedUsersException () throws Exception {
        User u = getUserManager().createUser(TestIdentityProvider.ID_EXCEPTION, null);
        u.setProperty(DefaultSyncContext.REP_EXTERNAL_ID, getValueFactory().createValue(new ExternalIdentityRef(TestIdentityProvider.ID_EXCEPTION, idp.getName()).getString()));
        root.commit();

        String[] result = syncMBean.listOrphanedUsers();
        assertEquals(0, result.length);
    }

    @Test
    public void testListOrphanedUsersThrowingHandler() throws Exception {
        sync(new TestIdentityProvider.TestUser("thirdUser", idp.getName()), idp);
        sync(new TestIdentityProvider.TestGroup("g", idp.getName()), idp);

        String[] result = createThrowingSyncMBean(false).listOrphanedUsers();
        assertEquals(0, result.length);

        result = createThrowingSyncMBean(true).listOrphanedUsers();
        assertEquals(2, result.length);
        assertEquals(ImmutableSet.of("thirdUser", "g"), ImmutableSet.copyOf(result));
    }

    @Test
    public void testPurgeOrphanedUsersNoPurge() {
        syncMBean.syncAllExternalUsers();

        String[] result = syncMBean.purgeOrphanedUsers();
        assertEquals(0, result.length);
    }

    @Test
    public void testPurgeOrphanedUsers() throws Exception {
        syncMBean.syncAllExternalUsers();

        sync(new TestIdentityProvider.TestUser("thirdUser", idp.getName()), idp);
        sync(new TestIdentityProvider.TestGroup("g", idp.getName()), idp);

        String[] result = syncMBean.purgeOrphanedUsers();
        assertResultMessages(result, ImmutableMap.of("thirdUser", "del", "g", "del"));

        UserManager userManager = getUserManager();
        assertNull(userManager.getAuthorizable("thirdUser"));
        assertNull(userManager.getAuthorizable("g"));
    }

    @Test
    public void testPurgeOrphanedUsersForeign() throws Exception {
        sync(foreignIDP, "a", true);
        sync(foreignIDP, ID_TEST_USER, false);

        String[] result = syncMBean.purgeOrphanedUsers();
        assertEquals(0, result.length);
    }

    @Test
    public void testPurgeOrphanedUsersException() throws Exception {
        User u = getUserManager().createUser(TestIdentityProvider.ID_EXCEPTION, null);
        u.setProperty(DefaultSyncContext.REP_EXTERNAL_ID, getValueFactory().createValue(new ExternalIdentityRef(TestIdentityProvider.ID_EXCEPTION, idp.getName()).getString()));
        root.commit();

        String[] result = syncMBean.purgeOrphanedUsers();
        assertEquals(0, result.length);
    }

    @Test
    public void testPurgeOrphanedUsersThrowingHandler() throws Exception {
        sync(new TestIdentityProvider.TestUser("thirdUser", idp.getName()), idp);
        sync(new TestIdentityProvider.TestGroup("g", idp.getName()), idp);

        String[] result = createThrowingSyncMBean(false).purgeOrphanedUsers();
        assertEquals(0, result.length);

        UserManager userManager = getUserManager();
        assertNotNull(userManager.getAuthorizable("thirdUser"));
        assertNotNull(userManager.getAuthorizable("g"));
    }

    /**
     * @see <a href="https://issues.apache.org/jira/browse/OAK-4362">OAK-4362</a>
     */
    @Test
    public void testPurgeOrphanedUsersThrowingHandler2() throws Exception {
        sync(new TestIdentityProvider.TestUser("thirdUser", idp.getName()), idp);
        sync(new TestIdentityProvider.TestGroup("g", idp.getName()), idp);

        String[] result = createThrowingSyncMBean(true).purgeOrphanedUsers();
        assertResultMessages(result, ImmutableMap.of("thirdUser", "ERR", "g", "ERR"));

        UserManager userManager = getUserManager();
        assertNotNull(userManager.getAuthorizable("thirdUser"));
        assertNotNull(userManager.getAuthorizable("g"));
    }
    
    @Test
    public void testConvertToDynamicMembershipDefaultContext() throws Exception {
        sync(new TestIdentityProvider.TestUser(ID_TEST_USER, idp.getName()), idp);
        
        String[] result = syncMBean.convertToDynamicMembership();
        assertNotNull(result);
        assertEquals(0, result.length);
    }

    @Test
    public void testConvertToDynamicMembership() throws Exception {
        syncConfig.user().setMembershipNestingDepth(2);
        sync(idp.getUser(ID_TEST_USER), idp);

        syncConfig.user().setDynamicMembership(true);
        String[] result = syncMBean.convertToDynamicMembership();
        assertNotNull(result);
        assertEquals(1, result.length);
        
        ResultMessages expected = new ResultMessages();
        expected.append(Collections.singletonList(new DefaultSyncResultImpl(DefaultSyncContext.createSyncedIdentity(getUserManager().getAuthorizable(ID_TEST_USER)), SyncResult.Status.UPDATE)));
        assertArrayEquals(expected.getMessages(), result);
    }

    @Test
    public void testConvertToDynamicMembershipNoop() throws Exception {
        syncConfig.user().setDynamicMembership(true);

        syncMBean.syncExternalUsers(new String[] {idp.getUser(ID_TEST_USER).getExternalId().getString()});
        
        Authorizable a = getUserManager().getAuthorizable(ID_TEST_USER);
        assertNotNull(a);
        
        String[] result = syncMBean.convertToDynamicMembership();
        assertNotNull(result);
        assertEquals(1, result.length);

        ResultMessages expected = new ResultMessages();
        expected.append(Collections.singletonList(new DefaultSyncResultImpl(DefaultSyncContext.createSyncedIdentity(a), SyncResult.Status.NOP)));
        assertArrayEquals(expected.getMessages(), result);
    }

    @Test
    public void testConvertToDynamicMembershipForeign() throws Exception {
        String idpName = "anotherIDP";
        TestIdentityProvider anotherIdp = new TestIdentityProvider(idpName);
        sync(anotherIdp.getUser(ID_TEST_USER), anotherIdp);

        syncConfig.user().setDynamicMembership(true);
        Authorizable a = getUserManager().getAuthorizable(ID_TEST_USER);
        assertNotNull(a);

        String[] result = syncMBean.convertToDynamicMembership();
        assertNotNull(result);
        assertEquals(0, result.length);
    }

    /**
     * SyncHandler implementation that throws Exceptions.
     */
    private final class ThrowingSyncHandler extends DefaultSyncHandler {

        private static final String NAME = "throwing";
        private static final String NAME_ALLOWS_IDENTITY_LISTING = "throwingExceptListIdentities";

        boolean allowsListIdentities;

        private ThrowingSyncHandler(boolean allowsListIdentities) {
            super(syncConfig);
            this.allowsListIdentities = allowsListIdentities;
        }

        @NotNull
        @Override
        public String getName() {
            return allowsListIdentities ? NAME_ALLOWS_IDENTITY_LISTING : NAME;
        }

        @NotNull
        @Override
        public SyncContext createContext(@NotNull ExternalIdentityProvider idp, @NotNull UserManager userManager, @NotNull ValueFactory valueFactory) {
            return new DefaultSyncContext(syncConfig, idp, userManager, valueFactory) {
                @NotNull
                @Override
                public SyncResult sync(@NotNull ExternalIdentity identity) throws SyncException {
                    throw new SyncException("sync " + identity);
                }

                @NotNull
                @Override
                public SyncResult sync(@NotNull String id) throws SyncException {
                    throw new SyncException("sync " + id);
                }
            };
        }

        @Nullable
        @Override
        public SyncedIdentity findIdentity(@NotNull UserManager userManager, @NotNull String id) throws RepositoryException {
            throw new RepositoryException("findIdentity");
        }

        @Override
        public boolean requiresSync(@NotNull SyncedIdentity identity) {
            return false;
        }

        @NotNull
        @Override
        public Iterator<SyncedIdentity> listIdentities(@NotNull UserManager userManager) throws RepositoryException {
            if (!allowsListIdentities) {
                throw new RepositoryException("listIdentities");
            } else {
                return super.listIdentities(userManager);
            }
        }
    }
}
