/*
 * The contents of this file are subject to the terms of the Common Development and
 * Distribution License (the License). You may not use this file except in compliance with the
 * License.
 *
 * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
 * specific language governing permission and limitations under the License.
 *
 * When distributing Covered Software, include this CDDL Header Notice in each file and include
 * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
 * Header, with the fields enclosed by brackets [] replaced by your own identifying
 * information: "Portions copyright [year] [name of copyright owner]".
 *
 * Copyright 2014 ForgeRock AS.
 * Portions copyright 2019 Open Source Solution Technology Corporation
 */

package org.forgerock.openam.scripting;

import org.mozilla.javascript.Wrapper;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Test;

import javax.script.Bindings;
import javax.script.ScriptEngine;
import javax.script.ScriptEngineManager;
import javax.script.ScriptException;
import javax.script.SimpleBindings;
import java.util.Arrays;
import java.util.regex.Pattern;

import static org.fest.assertions.Assertions.assertThat;
import static org.testng.Assert.fail;

/**
 * Tests that verify that the sandbox is functioning correctly. This test suite is abstract and expects individual
 * language implementations to sub-class and provide the script engine. It further assumes that each script engine
 * supports basic Java-style syntax for constructing objects and calling methods (true for Javascript and Groovy).
 * <p/>
 * <strong>Note:</strong> just because these tests pass, does not mean the sandbox is watertight! For example, if you
 * white-list the java.lang.reflect.* classes then script authors have pretty much a free hand. So either only
 * white-list exactly those classes that the script should have access to (and never white-list reflection or
 * java.lang.Class) or run OpenAM with a SecurityManager enabled and an appropriate security policy in place.
 */
public abstract class AbstractSandboxTest {

    private ScriptEngine scriptEngine;

    @BeforeMethod
    public void setupEngine() {
        StandardScriptEngineManager scriptEngineManager = new StandardScriptEngineManager();
        // Set up very permissive whitelist, and then blacklist our one class
        scriptEngineManager.setConfiguration(ScriptEngineConfiguration.builder()
                .withWhiteList(Arrays.asList(Pattern.compile(".*")))
                .withBlackList(Arrays.asList(Pattern.compile(Pattern.quote(ForbiddenFruit.class.getName()))))
                .build());

        scriptEngine = getEngine(scriptEngineManager);
    }

    protected abstract ScriptEngine getEngine(ScriptEngineManager manager);

    /**
     * Convenience wrapper that constructs a program from the given lines of code and then evaluates it in the sandbox.
     */
    @SuppressWarnings("unchecked")
    private <T> T eval(Bindings bindings, String...script) throws ScriptException {
        Object result = scriptEngine.eval(scriptEngine.getFactory().getProgram(script), bindings);
        while (result instanceof Wrapper) {
            result = ((Wrapper) result).unwrap();
        }
        return (T) result;
    }

    protected <T> T eval(String...script) throws ScriptException {
        return eval(new SimpleBindings(), script);
    }

    @Test(expectedExceptions = ScriptException.class)
    public void shouldNotBeAbleToInstantiateBlackListedClasses() throws Exception {
        eval("new " + ForbiddenFruit.class.getName() + "();");
    }

    @Test
    public void shouldBeAbleToInstantiateWhiteListedClasses() throws Exception {
        Allowed result = eval("new " + Allowed.class.getName() + "();");
        assertThat(result).isNotNull();
    }

    @Test
    public void shouldBeAllowedToAccessWhiteListedInstance() throws Exception {
        // Given
        Allowed allowed = new Allowed();
        Bindings bindings = new SimpleBindings();
        bindings.put("allowed", allowed);

        // When
        eval(bindings, "allowed.setDirty()");

        // Then
        assertThat(allowed.dirty).isTrue();
    }

    @Test
    public void shouldNotBeAllowedToAccessBlackListedMember() throws Exception {
        // Given
        Allowed allowed = new Allowed();
        Bindings bindings = new SimpleBindings();
        bindings.put("allowed", allowed);

        // When
        try {
            eval(bindings, "allowed.forbiddenFruit.setDirty()");
            fail("Sandbox failed to protect access to black-listed member");
        } catch (ScriptException ex) {
            // Then
            assertThat(allowed.fruit.dirty).isFalse();
        }
    }

    @Test(expectedExceptions = ScriptException.class)
    public void shouldPreventInstantiationViaReflection() throws Exception {
        eval("java.lang.Class.forName('" + ForbiddenFruit.class.getName() + "').newInstance();");
    }

    @Test
    public void shouldPreventCallingStaticMethodsOnForbiddenClasses() throws Exception {
        try {
            eval(ForbiddenFruit.class.getName() + ".dangerous();");
            fail("Static method calls to black-listed classes should be forbidden.");
        } catch (ScriptException ex) {
            assertThat(ForbiddenFruit.danger).isFalse();
        }
    }

    public static class Allowed {
        private final ForbiddenFruit fruit = new ForbiddenFruit();
        private boolean dirty = false;

        public ForbiddenFruit getForbiddenFruit() {
            return fruit;
        }

        public void setDirty() {
            dirty = true;
        }
    }

    public static class ForbiddenFruit {
        private static boolean danger = false;
        private boolean dirty = false;

        public void setDirty() {
            dirty = true;
        }

        public static void dangerous() {
            danger = true;
        }
    }
}
