/**
 * 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.cxf.systest.jaxrs.cors;

import java.io.Closeable;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;

import com.fasterxml.jackson.jaxrs.json.JacksonJsonProvider;

import org.apache.cxf.helpers.IOUtils;
import org.apache.cxf.jaxrs.client.WebClient;
import org.apache.cxf.rs.security.cors.CorsHeaderConstants;
import org.apache.cxf.systest.jaxrs.AbstractSpringServer;
import org.apache.cxf.testutil.common.AbstractBusClientServerTestBase;
import org.apache.http.Header;
import org.apache.http.HeaderElement;
import org.apache.http.HttpEntity;
import org.apache.http.HttpResponse;
import org.apache.http.client.ClientProtocolException;
import org.apache.http.client.HttpClient;
import org.apache.http.client.methods.HttpDelete;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpOptions;
import org.apache.http.impl.client.HttpClientBuilder;

import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Ignore;
import org.junit.Test;

/**
 * Unit tests for CORS. This isn't precisely simple as it's turned out. 
 * 
 * Note that it's not the server's job to detect invalid CORS requests. If a client
 * fails to preflight, it's just not our job. However, also note that all 'actual' 
 * requests are treated as simple requests. In other words, a DELETE gets the same
 * treatment as a simple request. The 'hey, this is complex' test happens on the client,
 * which thus decides to do a preflight.
 * 
 */
public class CrossOriginSimpleTest extends AbstractBusClientServerTestBase {
    public static final int PORT = SpringServer.PORT;
    private WebClient configClient;

    @BeforeClass
    public static void startServers() throws Exception {
        assertTrue("server did not launch correctly", launchServer(SpringServer.class, true));
    }

    @Before
    public void before() {
        List<Object> providers = new ArrayList<Object>();
        providers.add(new JacksonJsonProvider());
        configClient = WebClient.create("http://localhost:" + PORT + "/config", providers);
    }

    private List<String> headerValues(Header[] headers) {
        List<String> values = new ArrayList<String>();
        for (Header h : headers) {
            for (HeaderElement e : h.getElements()) {
                values.add(e.getName());
            }
        }
        return values;
    }

    private void assertAllOrigin(boolean allOrigins, String[] originList, String[] requestOrigins,
                                 boolean permitted) throws ClientProtocolException, IOException {
        configureAllowOrigins(allOrigins, originList);

        HttpClient httpclient = HttpClientBuilder.create().build();
        HttpGet httpget = new HttpGet("http://localhost:" + PORT + "/untest/simpleGet/HelloThere");
        if (requestOrigins != null) {
            StringBuilder ob = new StringBuilder();
            for (String requestOrigin : requestOrigins) {
                ob.append(requestOrigin);
                ob.append(" "); // extra trailing space won't hurt.
            }
            httpget.addHeader("Origin", ob.toString());
        }
        HttpResponse response = httpclient.execute(httpget);
        assertEquals(200, response.getStatusLine().getStatusCode());
        HttpEntity entity = response.getEntity();
        String e = IOUtils.toString(entity.getContent(), "utf-8");

        assertEquals("HelloThere", e); // ensure that we didn't bust the operation itself.
        assertOriginResponse(allOrigins, requestOrigins, permitted, response);
        if (httpclient instanceof Closeable) {
            ((Closeable)httpclient).close();
        }

    }

    private void assertOriginResponse(boolean allOrigins, String[] requestOrigins, boolean permitted,
                                      HttpResponse response) {
        Header[] aaoHeaders = response.getHeaders(CorsHeaderConstants.HEADER_AC_ALLOW_ORIGIN);
        if (permitted) {
            assertNotNull(aaoHeaders);
            if (allOrigins) {
                assertEquals(1, aaoHeaders.length);
                assertEquals("*", aaoHeaders[0].getValue());
            } else {
                List<String> ovalues = headerValues(aaoHeaders);
                assertEquals(1, ovalues.size()); // get back one ac-allow-origin header.
                String[] origins = ovalues.get(0).split(" +");
                for (int x = 0; x < requestOrigins.length; x++) {
                    assertEquals(requestOrigins[x], origins[x]);
                }
            }
        } else {
            // Origin: null? We don't use it and it's not in the CORS spec.
            assertTrue(aaoHeaders == null || aaoHeaders.length == 0);
        }
    }

    private void configureAllowOrigins(boolean allOrigins, String[] originList) {
        if (allOrigins) {
            originList = new String[0];
        }
        // tell filter what to do.
        String confResult = configClient.accept("text/plain").replacePath("/setOriginList")
            .type("application/json").post(originList, String.class);
        assertEquals("ok", confResult);
    }
    
    @Test
    public void failNoOrigin() throws Exception {
        assertAllOrigin(true, null, null, false);
    }

    @Test
    public void allowStarPassOne() throws Exception {
        // Allow *, pass origin
        assertAllOrigin(true, null, new String[] {
            "http://localhost:" + PORT
        }, true);
    }
    
    @Test
    public void preflightPostClassAnnotationFail() throws ClientProtocolException, IOException {
        HttpClient httpclient = HttpClientBuilder.create().build();
        HttpOptions httpoptions = new HttpOptions("http://localhost:" + PORT + "/antest/unannotatedPost");
        httpoptions.addHeader("Origin", "http://in.org");
        // nonsimple header
        httpoptions.addHeader("Content-Type", "application/json");
        httpoptions.addHeader(CorsHeaderConstants.HEADER_AC_REQUEST_METHOD, "POST");
        httpoptions.addHeader(CorsHeaderConstants.HEADER_AC_REQUEST_HEADERS, "X-custom-1");
        HttpResponse response = httpclient.execute(httpoptions);
        assertEquals(200, response.getStatusLine().getStatusCode());
        assertEquals(0, response.getHeaders(CorsHeaderConstants.HEADER_AC_ALLOW_ORIGIN).length);
        assertEquals(0, response.getHeaders(CorsHeaderConstants.HEADER_AC_ALLOW_HEADERS).length);
        assertEquals(0, response.getHeaders(CorsHeaderConstants.HEADER_AC_ALLOW_METHODS).length);
        if (httpclient instanceof Closeable) {
            ((Closeable)httpclient).close();
        }

    }
    
    @Test
    public void preflightPostClassAnnotationFail2() throws ClientProtocolException, IOException {
        HttpClient httpclient = HttpClientBuilder.create().build();
        HttpOptions httpoptions = new HttpOptions("http://localhost:" + PORT + "/antest/unannotatedPost");
        httpoptions.addHeader("Origin", "http://area51.mil:31415");
        httpoptions.addHeader("Content-Type", "application/json");
        httpoptions.addHeader(CorsHeaderConstants.HEADER_AC_REQUEST_METHOD, "POST");
        httpoptions.addHeader(CorsHeaderConstants.HEADER_AC_REQUEST_HEADERS, "X-custom-3");
        HttpResponse response = httpclient.execute(httpoptions);
        assertEquals(200, response.getStatusLine().getStatusCode());
        assertEquals(0, response.getHeaders(CorsHeaderConstants.HEADER_AC_ALLOW_ORIGIN).length);
        assertEquals(0, response.getHeaders(CorsHeaderConstants.HEADER_AC_ALLOW_HEADERS).length);
        assertEquals(0, response.getHeaders(CorsHeaderConstants.HEADER_AC_ALLOW_METHODS).length);
        if (httpclient instanceof Closeable) {
            ((Closeable)httpclient).close();
        }

    }
    
    @Test
    public void preflightPostClassAnnotationPass() throws ClientProtocolException, IOException {
        HttpClient httpclient = HttpClientBuilder.create().build();
        HttpOptions httpoptions = new HttpOptions("http://localhost:" + PORT + "/antest/unannotatedPost");
        httpoptions.addHeader("Origin", "http://area51.mil:31415");
        httpoptions.addHeader("Content-Type", "application/json");
        httpoptions.addHeader(CorsHeaderConstants.HEADER_AC_REQUEST_METHOD, "POST");
        httpoptions.addHeader(CorsHeaderConstants.HEADER_AC_REQUEST_HEADERS, "X-custom-1");
        HttpResponse response = httpclient.execute(httpoptions);
        assertEquals(200, response.getStatusLine().getStatusCode());
        Header[] origin = response.getHeaders(CorsHeaderConstants.HEADER_AC_ALLOW_ORIGIN);
        assertEquals(1, origin.length);
        assertEquals("http://area51.mil:31415", origin[0].getValue());
        Header[] method = response.getHeaders(CorsHeaderConstants.HEADER_AC_ALLOW_METHODS);
        assertEquals(1, method.length);
        assertEquals("POST", method[0].getValue());
        Header[] requestHeaders = response.getHeaders(CorsHeaderConstants.HEADER_AC_ALLOW_HEADERS);
        assertEquals(1, requestHeaders.length);
        assertEquals("X-custom-1", requestHeaders[0].getValue());
        if (httpclient instanceof Closeable) {
            ((Closeable)httpclient).close();
        }

    }
    
    @Test
    public void preflightPostClassAnnotationPass2() throws ClientProtocolException, IOException {
        HttpClient httpclient = HttpClientBuilder.create().build();
        HttpOptions httpoptions = new HttpOptions("http://localhost:" + PORT + "/antest/unannotatedPost");
        httpoptions.addHeader("Origin", "http://area51.mil:31415");
        httpoptions.addHeader("Content-Type", "application/json");
        httpoptions.addHeader(CorsHeaderConstants.HEADER_AC_REQUEST_METHOD, "POST");
        httpoptions.addHeader(CorsHeaderConstants.HEADER_AC_REQUEST_HEADERS, "X-custom-1, X-custom-2");
        HttpResponse response = httpclient.execute(httpoptions);
        assertEquals(200, response.getStatusLine().getStatusCode());
        Header[] origin = response.getHeaders(CorsHeaderConstants.HEADER_AC_ALLOW_ORIGIN);
        assertEquals(1, origin.length);
        assertEquals("http://area51.mil:31415", origin[0].getValue());
        Header[] method = response.getHeaders(CorsHeaderConstants.HEADER_AC_ALLOW_METHODS);
        assertEquals(1, method.length);
        assertEquals("POST", method[0].getValue());
        Header[] requestHeaders = response.getHeaders(CorsHeaderConstants.HEADER_AC_ALLOW_HEADERS);
        assertEquals(1, requestHeaders.length);
        assertTrue(requestHeaders[0].getValue().contains("X-custom-1"));
        assertTrue(requestHeaders[0].getValue().contains("X-custom-2"));
        if (httpclient instanceof Closeable) {
            ((Closeable)httpclient).close();
        }

    }
    
    @Test
    public void simplePostClassAnnotation() throws ClientProtocolException, IOException {
        HttpClient httpclient = HttpClientBuilder.create().build();
        HttpOptions httpoptions = new HttpOptions("http://localhost:" + PORT + "/antest/unannotatedPost");
        httpoptions.addHeader("Origin", "http://in.org");
        // nonsimple header
        httpoptions.addHeader("Content-Type", "text/plain");
        httpoptions.addHeader(CorsHeaderConstants.HEADER_AC_REQUEST_METHOD, "POST");
        HttpResponse response = httpclient.execute(httpoptions);
        assertEquals(200, response.getStatusLine().getStatusCode());
        if (httpclient instanceof Closeable) {
            ((Closeable)httpclient).close();
        }

    }
    
    @Test
    public void allowStarPassNone() throws Exception {
        // allow *, no origin
        assertAllOrigin(true, null, null, false);
    }
    
    @Test
    public void allowOnePassOne() throws Exception {
        // allow one, pass that one
        assertAllOrigin(false, new String[] {
            "http://localhost:" + PORT
        }, new String[] {
            "http://localhost:" + PORT
        }, true);
    } 
    
    @Test
    public void allowOnePassWrong() throws Exception {
        // allow one, pass something else
        assertAllOrigin(false, new String[] {
            "http://localhost:" + PORT
        }, new String[] {
            "http://area51.mil:31315",
        }, false);
    }
    
    @Test
    public void allowTwoPassOne() throws Exception {
        // allow two, pass one
        assertAllOrigin(false, new String[] {
            "http://localhost:" + PORT, "http://area51.mil:3141"
        }, new String[] {
            "http://localhost:" + PORT
        }, true);
    }
    
    @Test
    public void allowTwoPassTwo() throws Exception {
        // allow two, pass two
        assertAllOrigin(false, new String[] {
            "http://localhost:" + PORT, "http://area51.mil:3141"
        }, new String[] {
            "http://localhost:" + PORT, "http://area51.mil:3141"
        }, true);
    }
    
    @Test
    public void allowTwoPassThree() throws Exception {
        // allow two, pass three
        assertAllOrigin(false, new String[] {
            "http://localhost:" + PORT, "http://area51.mil:3141"
        }, new String[] {
            "http://localhost:" + PORT, "http://area51.mil:3141", "http://hogwarts.edu:9"
        }, false);

    }
    
    @Test
    public void testAllowCredentials() throws Exception {
        String r = configClient.replacePath("/setAllowCredentials/true")
                .accept("text/plain").post(null, String.class);
        assertEquals("ok", r);
        
        HttpClient httpclient = HttpClientBuilder.create().build();
        HttpGet httpget = new HttpGet("http://localhost:" + PORT + "/untest/simpleGet/HelloThere");
        httpget.addHeader("Origin", "http://localhost:" + PORT);

        HttpResponse response = httpclient.execute(httpget);
        assertEquals(200, response.getStatusLine().getStatusCode());
        assertAllowCredentials(response, true);
        if (httpclient instanceof Closeable) {
            ((Closeable)httpclient).close();
        }

    }
    
    @Test
    public void testForbidCredentials() throws Exception {
        String r = configClient.replacePath("/setAllowCredentials/false")
                .accept("text/plain").post(null, String.class);
        assertEquals("ok", r);
        
        HttpClient httpclient = HttpClientBuilder.create().build();
        HttpGet httpget = new HttpGet("http://localhost:" + PORT + "/untest/simpleGet/HelloThere");
        httpget.addHeader("Origin", "http://localhost:" + PORT);

        HttpResponse response = httpclient.execute(httpget);
        assertEquals(200, response.getStatusLine().getStatusCode());
        assertAllowCredentials(response, false);
        if (httpclient instanceof Closeable) {
            ((Closeable)httpclient).close();
        }

    }
    
    @Test
    public void testNonSimpleActualRequest() throws Exception {
        configureAllowOrigins(true, null);
        String r = configClient.replacePath("/setAllowCredentials/false")
            .accept("text/plain").post(null, String.class);
        assertEquals("ok", r);
        
        HttpClient httpclient = HttpClientBuilder.create().build();
        HttpDelete httpdelete = new HttpDelete("http://localhost:" + PORT + "/untest/delete");
        httpdelete.addHeader("Origin", "http://localhost:" + PORT);

        HttpResponse response = httpclient.execute(httpdelete);
        assertEquals(200, response.getStatusLine().getStatusCode());
        assertAllowCredentials(response, false);
        assertOriginResponse(true, null, true, response);
        if (httpclient instanceof Closeable) {
            ((Closeable)httpclient).close();
        }
    }

    private void assertAllowCredentials(HttpResponse response, boolean correct) {
        Header[] aaoHeaders = response.getHeaders(CorsHeaderConstants.HEADER_AC_ALLOW_CREDENTIALS);
        assertEquals(1, aaoHeaders.length);
        assertEquals(Boolean.toString(correct), aaoHeaders[0].getValue());
    }
    
    @Test
    public void testAnnotatedSimple() throws Exception {
        configureAllowOrigins(true, null);
        String r = configClient.replacePath("/setAllowCredentials/false")
            .accept("text/plain").post(null, String.class);
        assertEquals("ok", r);
        HttpClient httpclient = HttpClientBuilder.create().build();
        HttpGet httpget = new HttpGet("http://localhost:" + PORT + "/untest/annotatedGet/HelloThere");
        // this is the origin we expect to get.
        httpget.addHeader("Origin", "http://area51.mil:31415");
        HttpResponse response = httpclient.execute(httpget);
        assertEquals(200, response.getStatusLine().getStatusCode());
        assertOriginResponse(false, new String[]{"http://area51.mil:31415"}, true, response);
        assertAllowCredentials(response, false);
        List<String> exposeHeadersValues 
            = headerValues(response.getHeaders(CorsHeaderConstants.HEADER_AC_EXPOSE_HEADERS));
        assertEquals(Arrays.asList(new String[] {"X-custom-3", "X-custom-4" }), exposeHeadersValues);
        if (httpclient instanceof Closeable) {
            ((Closeable)httpclient).close();
        }

    }
    
    @Test
    public void testAnnotatedMethodPreflight() throws Exception {
        configureAllowOrigins(true, null);
        String r = configClient.replacePath("/setAllowCredentials/false")
            .accept("text/plain").post(null, String.class);
        assertEquals("ok", r);
        HttpClient httpclient = HttpClientBuilder.create().build();
        HttpOptions http = new HttpOptions("http://localhost:" + PORT + "/untest/annotatedPut");
        // this is the origin we expect to get.
        http.addHeader("Origin", "http://area51.mil:31415");
        http.addHeader(CorsHeaderConstants.HEADER_AC_REQUEST_METHOD, "PUT");
        http.addHeader(CorsHeaderConstants.HEADER_AC_REQUEST_HEADERS, "X-custom-1, x-custom-2");
        HttpResponse response = httpclient.execute(http);
        assertEquals(200, response.getStatusLine().getStatusCode());
        assertOriginResponse(false, new String[]{"http://area51.mil:31415"}, true, response);
        assertAllowCredentials(response, true);
        List<String> exposeHeadersValues 
            = headerValues(response.getHeaders(CorsHeaderConstants.HEADER_AC_EXPOSE_HEADERS));
        // preflight never returns Expose-Headers
        assertEquals(Collections.emptyList(), exposeHeadersValues);
        List<String> allowHeadersValues 
            = headerValues(response.getHeaders(CorsHeaderConstants.HEADER_AC_ALLOW_HEADERS));
        assertEquals(Arrays.asList(new String[] {"X-custom-1", "x-custom-2" }), allowHeadersValues);
        if (httpclient instanceof Closeable) {
            ((Closeable)httpclient).close();
        }

    }
    
    @Test
    public void testAnnotatedMethodPreflight2() throws Exception {
        configureAllowOrigins(true, null);
        String r = configClient.replacePath("/setAllowCredentials/false")
            .accept("text/plain").post(null, String.class);
        assertEquals("ok", r);
        HttpClient httpclient = HttpClientBuilder.create().build();
        HttpOptions http = new HttpOptions("http://localhost:" + PORT + "/untest/annotatedPut2");
        // this is the origin we expect to get.
        http.addHeader("Origin", "http://area51.mil:31415");
        http.addHeader(CorsHeaderConstants.HEADER_AC_REQUEST_METHOD, "PUT");
        http.addHeader(CorsHeaderConstants.HEADER_AC_REQUEST_HEADERS, "X-custom-1, x-custom-2");
        HttpResponse response = httpclient.execute(http);
        assertEquals(200, response.getStatusLine().getStatusCode());
        assertOriginResponse(false, new String[]{"http://area51.mil:31415"}, true, response);
        assertAllowCredentials(response, true);
        List<String> exposeHeadersValues 
            = headerValues(response.getHeaders(CorsHeaderConstants.HEADER_AC_EXPOSE_HEADERS));
        // preflight never returns Expose-Headers
        assertEquals(Collections.emptyList(), exposeHeadersValues);
        List<String> allowHeadersValues 
            = headerValues(response.getHeaders(CorsHeaderConstants.HEADER_AC_ALLOW_HEADERS));
        assertEquals(Arrays.asList(new String[] {"X-custom-1", "x-custom-2" }), allowHeadersValues);
        if (httpclient instanceof Closeable) {
            ((Closeable)httpclient).close();
        }

    }
    
    @Test
    public void testAnnotatedClassCorrectOrigin() throws Exception {
        HttpClient httpclient = HttpClientBuilder.create().build();
        HttpGet httpget = new HttpGet("http://localhost:" + PORT + "/antest/simpleGet/HelloThere");
        httpget.addHeader("Origin", "http://area51.mil:31415");

        HttpResponse response = httpclient.execute(httpget);
        assertEquals(200, response.getStatusLine().getStatusCode());
        HttpEntity entity = response.getEntity();
        String e = IOUtils.toString(entity.getContent(), "utf-8");

        assertEquals("HelloThere", e); // ensure that we didn't bust the operation itself.
        assertOriginResponse(false, new String[] {"http://area51.mil:31415" }, true, response);
        if (httpclient instanceof Closeable) {
            ((Closeable)httpclient).close();
        }
    }
    
    @Test
    public void testAnnotatedClassWrongOrigin() throws Exception {
        HttpClient httpclient = HttpClientBuilder.create().build();
        HttpGet httpget = new HttpGet("http://localhost:" + PORT + "/antest/simpleGet/HelloThere");
        httpget.addHeader("Origin", "http://su.us:1001");

        HttpResponse response = httpclient.execute(httpget);
        assertEquals(200, response.getStatusLine().getStatusCode());
        HttpEntity entity = response.getEntity();
        String e = IOUtils.toString(entity.getContent(), "utf-8");

        assertEquals("HelloThere", e);
        assertOriginResponse(false, null, false, response);
        if (httpclient instanceof Closeable) {
            ((Closeable)httpclient).close();
        }
    }
    
    @Test
    public void testAnnotatedLocalPreflight() throws Exception {
        configureAllowOrigins(true, null);
        String r = configClient.replacePath("/setAllowCredentials/false")
            .accept("text/plain").post(null, String.class);
        assertEquals("ok", r);
        
        HttpClient httpclient = HttpClientBuilder.create().build();
        HttpOptions http = new HttpOptions("http://localhost:" + PORT + "/antest/delete");
        // this is the origin we expect to get.
        http.addHeader("Origin", "http://area51.mil:3333");
        http.addHeader(CorsHeaderConstants.HEADER_AC_REQUEST_METHOD, "DELETE");
        HttpResponse response = httpclient.execute(http);
        assertEquals(200, response.getStatusLine().getStatusCode());
        assertOriginResponse(false, new String[]{"http://area51.mil:3333"}, true, response);
        assertAllowCredentials(response, false);
        List<String> exposeHeadersValues 
            = headerValues(response.getHeaders(CorsHeaderConstants.HEADER_AC_EXPOSE_HEADERS));
        // preflight never returns Expose-Headers
        assertEquals(Collections.emptyList(), exposeHeadersValues);
        List<String> allowedMethods     
            = headerValues(response.getHeaders(CorsHeaderConstants.HEADER_AC_ALLOW_METHODS));
        assertEquals(Arrays.asList("DELETE PUT"), allowedMethods);
        if (httpclient instanceof Closeable) {
            ((Closeable)httpclient).close();
        }
    }
    
    @Test
    public void testAnnotatedLocalPreflightNoGo() throws Exception {
        configureAllowOrigins(true, null);
        String r = configClient.replacePath("/setAllowCredentials/false")
            .accept("text/plain").post(null, String.class);
        assertEquals("ok", r);
        
        HttpClient httpclient = HttpClientBuilder.create().build();
        HttpOptions http = new HttpOptions("http://localhost:" + PORT + "/antest/delete");
        // this is the origin we expect to get.
        http.addHeader("Origin", "http://area51.mil:4444");
        http.addHeader(CorsHeaderConstants.HEADER_AC_REQUEST_METHOD, "DELETE");
        HttpResponse response = httpclient.execute(http);
        assertEquals(200, response.getStatusLine().getStatusCode());
        assertOriginResponse(false, new String[]{"http://area51.mil:4444"}, false, response);
        // we could check that the others are also missing.
        if (httpclient instanceof Closeable) {
            ((Closeable)httpclient).close();
        }
    }

    @Ignore
    public static class SpringServer extends AbstractSpringServer {
        public static final int PORT = allocatePortAsInt(SpringServer.class);

        public SpringServer() {
            super("/jaxrs_cors", PORT);
        }
    }
}
