package com.atlassian.stash.internal.auth;

import com.atlassian.bitbucket.AbstractServiceTest;
import com.atlassian.bitbucket.auth.AuthenticationSystemException;
import com.atlassian.bitbucket.auth.CaptchaRequiredAuthenticationException;
import com.atlassian.bitbucket.auth.IncorrectCaptchaAuthenticationException;
import com.atlassian.bitbucket.auth.IncorrectPasswordAuthenticationException;
import com.atlassian.bitbucket.i18n.I18nService;
import com.atlassian.bitbucket.i18n.KeyedMessage;
import com.atlassian.bitbucket.i18n.SimpleI18nService;
import com.atlassian.bitbucket.server.ApplicationPropertiesService;
import com.atlassian.bitbucket.server.FeatureManager;
import com.atlassian.bitbucket.server.StandardFeature;
import com.atlassian.bitbucket.user.ApplicationUser;
import com.atlassian.bitbucket.user.NoSuchUserException;
import com.atlassian.bitbucket.util.UncheckedOperation;
import com.atlassian.crowd.embedded.api.User;
import com.atlassian.crowd.model.user.UserWithAttributes;
import com.atlassian.stash.internal.crowd.CrowdControl;
import com.atlassian.stash.internal.user.CaptchaService;
import com.atlassian.stash.internal.user.CaptchaTicket;
import com.atlassian.stash.internal.user.InternalNormalUser;
import com.octo.captcha.service.CaptchaServiceException;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.runners.MockitoJUnitRunner;

import static org.mockito.Matchers.any;
import static org.mockito.Matchers.anyString;
import static org.mockito.Matchers.eq;
import static org.mockito.Mockito.*;
import static org.mockito.Mockito.same;

@RunWith(MockitoJUnitRunner.class)
public class DefaultCaptchaServiceTest extends AbstractServiceTest {
    private static final String USERNAME = "existingUser";

    @Mock
    private AuthOperation authOperation;
    @Mock
    private com.octo.captcha.service.CaptchaService captchaService;
    @Mock
    private CrowdControl crowdControl;
    @Mock
    private User crowdUser;
    @Mock
    private FeatureManager featureManager;
    @Spy
    private I18nService i18nService = new SimpleI18nService();
    @Mock
    private ApplicationPropertiesService propertiesService;
    private DefaultCaptchaService service;
    @Mock
    private InternalNormalUser stashUser;

    public DefaultCaptchaServiceTest() {
        super(DefaultCaptchaService.class, CaptchaService.class);
    }

    @Before
    public void setup() {
        service = new DefaultCaptchaService(captchaService, crowdControl, featureManager, i18nService, propertiesService);

        when(featureManager.isEnabled(StandardFeature.AUTH_CAPTCHA)).thenReturn(true);

        when(crowdUser.getName()).thenReturn(USERNAME);
        when(stashUser.getBackingCrowdUser()).thenReturn(crowdUser);
    }

    //The IncorrectPasswordAuthenticationException thrown by CrowdControl should be promoted to a
    //CaptchaRequiredAuthenticationException by the user service
    @Test(expected = CaptchaRequiredAuthenticationException.class)
    public void testAuthenticateRequiresCaptchaAfterTooManyFailedAttempts() {
        UserWithAttributes userWithAttributes = mock(UserWithAttributes.class);
        when(userWithAttributes.getValue(eq(DefaultCaptchaService.FAILED_AUTHENTICATION_ATTEMPT_COUNT))).thenReturn("2");

        doThrow(new IncorrectPasswordAuthenticationException(mock(KeyedMessage.class))).when(authOperation).perform();
        when(crowdControl.findUserWithAttributes(eq(USERNAME))).thenReturn(userWithAttributes);
        when(propertiesService.getMaxCaptchaAttempts()).thenReturn(3);

        try {
            CaptchaTicket token = service.checkCaptcha(USERNAME, null);
            service.authenticateWithCaptcha(token, authOperation);
        } finally {
            verify(crowdControl).setUserAttribute(userWithAttributes, DefaultCaptchaService.FAILED_AUTHENTICATION_ATTEMPT_COUNT, 3L);
        }
    }

    @Test
    public void testAuthenticateWithCorrectCaptchaProceedsAndClearsAttemptCount() {
        UserWithAttributes userWithAttributes = mock(UserWithAttributes.class);
        when(userWithAttributes.getValue(eq(DefaultCaptchaService.FAILED_AUTHENTICATION_ATTEMPT_COUNT))).thenReturn("2");

        when(captchaService.validateResponseForID(eq("captchaId"), eq("right"))).thenReturn(true);
        when(authOperation.perform()).thenReturn(stashUser);
        when(crowdControl.findUserWithAttributes(eq(USERNAME))).thenReturn(userWithAttributes);
        when(propertiesService.getMaxCaptchaAttempts()).thenReturn(2);

        CaptchaTicket token = service.checkCaptcha(USERNAME, new CaptchaResponse("captchaId", "right"));
        assertNotNull(token);

        assertSame(stashUser, service.authenticateWithCaptcha(token, authOperation));
        verify(crowdControl).setUserAttribute(same(userWithAttributes),
                eq(DefaultCaptchaService.FAILED_AUTHENTICATION_ATTEMPT_COUNT),
                eq(0L));
    }

    @Test(expected = IncorrectPasswordAuthenticationException.class)
    public void testAuthenticateWithIncorrectPasswordIncrementsFailedAttemptCount() throws Exception {
        UserWithAttributes userWithAttributes = mock(UserWithAttributes.class);
        when(userWithAttributes.getValue(eq(DefaultCaptchaService.FAILED_AUTHENTICATION_ATTEMPT_COUNT))).thenReturn("0");

        doThrow(new IncorrectPasswordAuthenticationException(mock(KeyedMessage.class))).when(authOperation).perform();
        when(crowdControl.findUserWithAttributes(eq(USERNAME))).thenReturn(userWithAttributes);
        when(propertiesService.getMaxCaptchaAttempts()).thenReturn(3);

        CaptchaTicket token = service.checkCaptcha(USERNAME, null);
        assertNotNull(token);

        try {
            service.authenticateWithCaptcha(token, authOperation);
        } finally {
            verify(crowdControl).setUserAttribute(userWithAttributes, DefaultCaptchaService.FAILED_AUTHENTICATION_ATTEMPT_COUNT, 1L);
        }
    }

    @Test(expected = IncorrectCaptchaAuthenticationException.class)
    public void testAuthenticateWithWrongCaptchaIncrementsFailedAttemptCount() throws Exception {
        UserWithAttributes userWithAttributes = mock(UserWithAttributes.class);
        when(userWithAttributes.getValue(eq(DefaultCaptchaService.FAILED_AUTHENTICATION_ATTEMPT_COUNT))).thenReturn("2");

        when(captchaService.validateResponseForID(eq("captchaId"), eq("wrong"))).thenReturn(false);
        when(crowdControl.findUserWithAttributes(eq(USERNAME))).thenReturn(userWithAttributes);
        when(propertiesService.getMaxCaptchaAttempts()).thenReturn(2);

        CaptchaTicket token = service.checkCaptcha(USERNAME, new CaptchaResponse("captchaId", "wrong"));
        assertNotNull(token);

        try {
            service.authenticateWithCaptcha(token, authOperation);
        } finally {
            //Should never try to authenticate if the CAPTCHA is wrong
            verify(crowdControl, never()).authenticate(anyString(), anyString());
            verify(crowdControl).setUserAttribute(userWithAttributes, DefaultCaptchaService.FAILED_AUTHENTICATION_ATTEMPT_COUNT, 3L);
        }
    }

    @Test(expected = CaptchaRequiredAuthenticationException.class)
    public void testCheckFailsIfRequiredCaptchaIsNotProvided() throws Exception {
        UserWithAttributes userWithAttributes = mock(UserWithAttributes.class);
        when(userWithAttributes.getValue(eq(DefaultCaptchaService.FAILED_AUTHENTICATION_ATTEMPT_COUNT))).thenReturn("2");

        when(crowdControl.findUserWithAttributes(eq(USERNAME))).thenReturn(userWithAttributes);
        when(propertiesService.getMaxCaptchaAttempts()).thenReturn(2);

        try {
            service.checkCaptcha(USERNAME, null);
        } finally {
            verify(crowdControl, never()).setUserAttribute(any(User.class), anyString(), anyString());
        }
    }

    @Test
    public void testCheckIgnoresBadAttemptCountAttributes() {
        //0 failed auth attempt count => no need for captcha
        UserWithAttributes userWithAttributes = mock(UserWithAttributes.class);
        when(userWithAttributes.getName()).thenReturn(USERNAME);
        when(userWithAttributes.getValue(eq(DefaultCaptchaService.FAILED_AUTHENTICATION_ATTEMPT_COUNT)))
                .thenReturn("Test");

        when(crowdControl.findUserWithAttributes(eq(USERNAME))).thenReturn(userWithAttributes);
        when(propertiesService.getMaxCaptchaAttempts()).thenReturn(3);

        // no exception is expected
        assertNotNull(service.checkCaptcha(USERNAME, null));
    }

    @Test
    public void testCheckIgnoresCaptchaIfNotNeeded() throws Exception {
        //0 failed auth attempt count => no need for captcha
        UserWithAttributes userWithAttributes = mock(UserWithAttributes.class);
        when(userWithAttributes.getValue(eq(DefaultCaptchaService.FAILED_AUTHENTICATION_ATTEMPT_COUNT))).thenReturn("0");

        when(crowdControl.findUserWithAttributes(eq(USERNAME))).thenReturn(userWithAttributes);
        when(propertiesService.getMaxCaptchaAttempts()).thenReturn(3);

        assertNotNull(service.checkCaptcha(USERNAME, new CaptchaResponse("foo", "bar")));
    }

    @Test(expected = AuthenticationSystemException.class)
    public void testCheckWhenCaptchaServiceThrowsAnException() {
        UserWithAttributes userWithAttributes = mock(UserWithAttributes.class);
        when(userWithAttributes.getValue(eq(DefaultCaptchaService.FAILED_AUTHENTICATION_ATTEMPT_COUNT))).thenReturn("2");

        doThrow(new CaptchaServiceException("Bad captcha ID"))
                .when(captchaService).validateResponseForID(eq("unknownCaptchaId"), eq("whatever"));
        when(crowdControl.findUserWithAttributes(eq(USERNAME))).thenReturn(userWithAttributes);
        when(propertiesService.getMaxCaptchaAttempts()).thenReturn(2);

        service.checkCaptcha(USERNAME, new CaptchaResponse("unknownCaptchaId", "whatever"));
    }

    @Test
    public void testClearCaptchaChallenge() {
        User user = mock(User.class);
        when(crowdControl.findUser(eq(USERNAME), eq(false))).thenReturn(user);

        service.clearCaptchaChallenge(USERNAME);

        verify(crowdControl).setUserAttribute(same(user), eq(DefaultCaptchaService.FAILED_AUTHENTICATION_ATTEMPT_COUNT), eq(0L));
    }

    @Test(expected = NoSuchUserException.class)
    public void testClearCaptchaChallengeUserNotFound() {
        service.clearCaptchaChallenge("does not exist");
    }

    @Test
    public void testIsCaptchaRequiredAtMax() {
        UserWithAttributes userWithAttributes = mock(UserWithAttributes.class);
        when(userWithAttributes.getValue(eq(DefaultCaptchaService.FAILED_AUTHENTICATION_ATTEMPT_COUNT))).thenReturn("3");
        when(crowdControl.findUserWithAttributes(eq(USERNAME))).thenReturn(userWithAttributes);
        when(propertiesService.getMaxCaptchaAttempts()).thenReturn(3);

        assertTrue(service.isCaptchaChallenged(USERNAME));
    }

    @Test
    public void testIsCaptchaRequiredUnderMax() {
        UserWithAttributes userWithAttributes = mock(UserWithAttributes.class);
        when(userWithAttributes.getValue(eq(DefaultCaptchaService.FAILED_AUTHENTICATION_ATTEMPT_COUNT))).thenReturn("2");
        when(crowdControl.findUserWithAttributes(eq(USERNAME))).thenReturn(userWithAttributes);
        when(propertiesService.getMaxCaptchaAttempts()).thenReturn(3);

        assertFalse(service.isCaptchaChallenged(USERNAME));
    }

    @Test(expected = NoSuchUserException.class)
    public void testIsCaptchaRequiredUserNotFound() {
        service.isCaptchaChallenged("does not exist");
    }

    private interface AuthOperation extends UncheckedOperation<ApplicationUser> {
    }
}
