/* // This software is subject to the terms of the Eclipse Public License v1.0 // Agreement, available at the following URL: // http://www.eclipse.org/legal/epl-v10.html. // You must accept the terms of that agreement to use this software. // // Copyright (C) 2002-2014 Pentaho and others // All Rights Reserved. */ package mondrian.xmla; import mondrian.olap.*; import mondrian.olap.Util.PropertyList; import mondrian.rolap.RolapConnectionProperties; import mondrian.test.*; import mondrian.tui.*; import mondrian.util.LockBox; import mondrian.xmla.test.XmlaTestContext; import junit.framework.AssertionFailedError; import org.olap4j.metadata.XmlaConstants; import org.custommonkey.xmlunit.XMLAssert; import org.w3c.dom.*; import org.xml.sax.SAXException; import java.io.*; import java.util.*; import java.util.regex.Pattern; import javax.servlet.*; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; /** * Extends FoodMartTestCase, adding support for testing XMLA specific * functionality, for example LAST_SCHEMA_UPDATE * * @author mkambol */ public abstract class XmlaBaseTestCase extends FoodMartTestCase { protected static final String LAST_SCHEMA_UPDATE_DATE = "xxxx-xx-xxTxx:xx:xx"; private static final String LAST_SCHEMA_UPDATE_NODE_NAME = "LAST_SCHEMA_UPDATE"; protected SortedMap<String, String> catalogNameUrls = null; private static int sessionIdCounter = 1000; private static Map<String, String> sessionIdMap = new HashMap<String, String>(); // session id property public static final String SESSION_ID_PROP = "session.id"; // request.type public static final String REQUEST_TYPE_PROP = "request.type";// data.source.info public static final String DATA_SOURCE_INFO_PROP = "data.source.info"; private static final String DATA_SOURCE_INFO_RESPONSE_PROP = "data.source.info.response"; public static final String DATA_SOURCE_INFO = "FoodMart";// catalog public static final String CATALOG_PROP = "catalog"; public static final String CATALOG_NAME_PROP = "catalog.name"; public static final String CATALOG = "FoodMart";// cube public static final String CUBE_NAME_PROP = "cube.name"; public static final String SALES_CUBE = "Sales";// format public static final String FORMAT_PROP = "format"; public static final String FORMAT_MULTI_DIMENSIONAL = "Multidimensional"; public static final String ROLE_PROP = "Role"; public static final String LOCALE_PROP = "locale"; protected static final boolean DEBUG = false; /** * Cache servlet instances between test invocations. Prevents creation * of many spurious MondrianServer instances. */ private final HashMap<List<String>, Servlet> SERVLET_CACHE = new HashMap<List<String>, Servlet>(); /** * Cache servlet instances between test invocations. Prevents creation * of many spurious MondrianServer instances. */ private final HashMap<List<String>, MondrianServer> SERVER_CACHE = new HashMap<List<String>, MondrianServer>(); protected void tearDown() throws Exception { super.tearDown(); for (MondrianServer server : SERVER_CACHE.values()) { server.shutdown(); } SERVER_CACHE.clear(); for (Servlet servlet : SERVLET_CACHE.values()) { servlet.destroy(); } SERVLET_CACHE.clear(); } protected String generateExpectedString(Properties props) throws Exception { String expectedStr = fileToString("response"); if (props != null) { // YES, duplicate the above String sessionId = getSessionId(Action.QUERY); if (sessionId != null) { props.put(SESSION_ID_PROP, sessionId); } expectedStr = Util.replaceProperties( expectedStr, Util.toMap(props)); } return expectedStr; } protected String generateRequestString(Properties props) throws Exception { String requestText = fileToString("request"); if (props != null) { String sessionId = getSessionId(Action.QUERY); if (sessionId != null) { props.put(SESSION_ID_PROP, sessionId); } requestText = Util.replaceProperties( requestText, Util.toMap(props)); } if (DEBUG) { System.out.println("requestText=" + requestText); } return requestText; } protected void validate( byte[] bytes, Document expectedDoc, TestContext testContext, boolean replace, boolean validate) throws Exception { if (validate && XmlUtil.supportsValidation()) { if (XmlaSupport.validateSoapXmlaUsingXpath(bytes)) { if (DEBUG) { System.out.println("XML Data is Valid"); } } } Document gotDoc = XmlUtil.parse(bytes); gotDoc = replaceLastSchemaUpdateDate(gotDoc); String gotStr = XmlUtil.toString(gotDoc, true); gotStr = maskVersion(gotStr); gotStr = testContext.upgradeActual(gotStr); if (expectedDoc == null) { if (replace) { getDiffRepos().amend("${response}", gotStr); } return; } expectedDoc = replaceLastSchemaUpdateDate(expectedDoc); String expectedStr = XmlUtil.toString(expectedDoc, true); try { XMLAssert.assertXMLEqual(expectedStr, gotStr); } catch (AssertionFailedError e) { // Let DiffRepository do the comparison. It will output // a textual difference, and will update the logfile, // XmlaBasicTest.log.xml. If you agree with the change, // copy this file to XmlaBasicTest.ref.xml. if (replace) { gotStr = gotStr.replaceAll( " SessionId=\"[^\"]*\" ", " SessionId=\"\\${session.id}\" "); getDiffRepos().assertEquals( "response", "${response}", gotStr); } else { throw e; } } } public void doTest(Properties props) throws Exception { String requestText = generateRequestString(props); Document reqDoc = XmlUtil.parseString(requestText); Servlet servlet = getServlet(getTestContext()); byte[] bytes = XmlaSupport.processSoapXmla(reqDoc, servlet); String expectedStr = generateExpectedString(props); Document expectedDoc = XmlUtil.parseString(expectedStr); validate(bytes, expectedDoc, TestContext.instance(), true, true); } protected void doTest( MockHttpServletRequest req, Properties props) throws Exception { String requestText = generateRequestString(props); MockHttpServletResponse res = new MockHttpServletResponse(); res.setCharacterEncoding("UTF-8"); Servlet servlet = getServlet(getTestContext()); servlet.service(req, res); int statusCode = res.getStatusCode(); if (statusCode == HttpServletResponse.SC_OK) { byte[] bytes = res.toByteArray(); String expectedStr = generateExpectedString(props); Document expectedDoc = XmlUtil.parseString(expectedStr); validate(bytes, expectedDoc, TestContext.instance(), true, true); } else if (statusCode == HttpServletResponse.SC_CONTINUE) { // remove the Expect header from request and try again if (DEBUG) { System.out.println("Got CONTINUE"); } req.clearHeader(XmlaRequestCallback.EXPECT); req.setBodyContent(requestText); servlet.service(req, res); statusCode = res.getStatusCode(); if (statusCode == HttpServletResponse.SC_OK) { byte[] bytes = res.toByteArray(); String expectedStr = generateExpectedString(props); Document expectedDoc = XmlUtil.parseString(expectedStr); validate( bytes, expectedDoc, TestContext.instance(), true, true); } else { fail("Bad status code: " + statusCode); } } else { fail("Bad status code: " + statusCode); } } protected void helperTestExpect(boolean doSessionId) { if (doSessionId) { Util.discard(getSessionId(Action.CREATE)); } MockHttpServletRequest req = new MockHttpServletRequest(); req.setMethod("POST"); req.setContentType("text/xml"); req.setHeader( XmlaRequestCallback.EXPECT, XmlaRequestCallback.EXPECT_100_CONTINUE); Properties props = new Properties(); addDatasourceInfoResponseKey(props); try { doTest(req, props); } catch (Exception e) { throw new RuntimeException(e); } } protected void helperTest(boolean doSessionId) { if (doSessionId) { getSessionId(Action.CREATE); } Properties props = new Properties(); addDatasourceInfoResponseKey(props); try { doTest(props); } catch (Exception e) { throw new RuntimeException(e); } } protected void addDatasourceInfoResponseKey(Properties props) { XmlaTestContext s = new XmlaTestContext(); String con = s.getConnectString().replaceAll("&","&"); PropertyList pl = Util.parseConnectString(con); pl.remove(RolapConnectionProperties.Jdbc.name()); pl.remove(RolapConnectionProperties.JdbcUser.name()); pl.remove(RolapConnectionProperties.JdbcPassword.name()); props.setProperty(DATA_SOURCE_INFO_RESPONSE_PROP, pl.toString()); } static class CallBack implements XmlaRequestCallback { public CallBack() { } public void init(ServletConfig servletConfig) throws ServletException { } public boolean processHttpHeader( HttpServletRequest request, HttpServletResponse response, Map<String, Object> context) throws Exception { return true; } public void preAction( HttpServletRequest request, Element[] requestSoapParts, Map<String, Object> context) throws Exception { } public String generateSessionId(Map<String, Object> context) { return null; } public void postAction( HttpServletRequest request, HttpServletResponse response, byte[][] responseSoapParts, Map<String, Object> context) throws Exception { } } public XmlaBaseTestCase() { } public XmlaBaseTestCase(String name) { super(name); } protected abstract DiffRepository getDiffRepos(); protected String fileToString(String filename) throws Exception { String var = "${" + filename + "}"; String s = getDiffRepos().expand(null, var); if (s.startsWith("$")) { getDiffRepos().amend(var, "\n\n"); } // Give derived class a chance to change the content. s = filter(getDiffRepos().getCurrentTestCaseName(true), filename, s); return s; } protected Document replaceLastSchemaUpdateDate(Document doc) { NodeList elements = doc.getElementsByTagName(LAST_SCHEMA_UPDATE_NODE_NAME); for (int i = 0; i < elements.getLength(); i++) { Node node = elements.item(i); node.getFirstChild().setNodeValue( LAST_SCHEMA_UPDATE_DATE); } return doc; } private String ignoreLastUpdateDate(String document) { return document.replaceAll( "\"LAST_SCHEMA_UPDATE\": \"....-..-..T..:..:..\"", "\"LAST_SCHEMA_UPDATE\": \"" + LAST_SCHEMA_UPDATE_DATE + "\""); } protected Map<String, String> getCatalogNameUrls(TestContext testContext) { if (catalogNameUrls == null) { catalogNameUrls = new TreeMap<String, String>(); String connectString = testContext.getConnectString(); Util.PropertyList connectProperties = Util.parseConnectString(connectString); String catalog = connectProperties.get( RolapConnectionProperties.Catalog.name()); catalogNameUrls.put("FoodMart", catalog); } return catalogNameUrls; } protected Servlet getServlet(TestContext testContext) throws IOException, ServletException, SAXException { getSessionId(Action.CLEAR); String connectString = testContext.getConnectString(); Map<String, String> catalogNameUrls = getCatalogNameUrls(testContext); connectString = filterConnectString(connectString); return XmlaSupport.makeServlet( connectString, catalogNameUrls, getServletCallbackClass().getName(), SERVLET_CACHE); } protected String filterConnectString(String original) { return original; } protected abstract Class<? extends XmlaRequestCallback> getServletCallbackClass(); protected Properties getDefaultRequestProperties(String requestType) { Properties props = new Properties(); props.setProperty(REQUEST_TYPE_PROP, requestType); props.setProperty(CATALOG_PROP, CATALOG); props.setProperty(CATALOG_NAME_PROP, CATALOG); props.setProperty(CUBE_NAME_PROP, SALES_CUBE); props.setProperty(FORMAT_PROP, FORMAT_MULTI_DIMENSIONAL); props.setProperty(DATA_SOURCE_INFO_PROP, DATA_SOURCE_INFO); return props; } protected Document fileToDocument(String filename, Properties props) throws IOException, SAXException { final String var = "${" + filename + "}"; String s = getDiffRepos().expand(null, var); s = Util.replaceProperties( s, Util.toMap(props)); if (s.equals(filename)) { s = "<?xml version='1.0'?><Empty/>"; getDiffRepos().amend(var, s); } // Give derived class a chance to change the content. s = filter(getDiffRepos().getCurrentTestCaseName(true), filename, s); return XmlUtil.parse(new ByteArrayInputStream(s.getBytes())); } /** * Filters the content of a test resource. The default implementation * returns the content unchanged, but a derived class might override this * method to change the content. * * @param testCaseName Name of current test case, e.g. "testFoo" * @param filename Name of requested content, e.g. "${request}" * @param content Content * @return Modified content */ protected String filter( String testCaseName, String filename, String content) { Util.discard(testCaseName); // might be used by derived class Util.discard(filename); // might be used by derived class return content; } /** * Executes an XMLA request, reading the text of the request and the * response from attributes in {@link #getDiffRepos()}. * * @param requestType Request type: "DISCOVER_DATASOURCES", "EXECUTE", etc. * @param props Properties for request * @param testContext Test context * @throws Exception on error */ public void doTest( String requestType, Properties props, TestContext testContext) throws Exception { doTest(requestType, props, testContext, null); } public void doTest( String requestType, Properties props, TestContext testContext, Role role) throws Exception { String requestText = fileToString("request"); requestText = testContext.upgradeQuery(requestText); doTestInline( requestType, requestText, "response", props, testContext, role); } public void doTestInline( String requestType, String requestText, String respFileName, Properties props, TestContext testContext) throws Exception { doTestInline( requestType, requestText, respFileName, props, testContext, null); } public void doTestInline( String requestType, String requestText, String respFileName, Properties props, TestContext testContext, Role role) throws Exception { String connectString = testContext.getConnectString(); Map<String, String> catalogNameUrls = getCatalogNameUrls(testContext); boolean xml = !requestText.contains("application/json"); if (!xml) { String responseStr = (respFileName != null) ? fileToString(respFileName) : null; doTestsJson( requestText, props, testContext, connectString, catalogNameUrls, responseStr, XmlaConstants.Content.Data, role); return; } final Document responseDoc = (respFileName != null) ? fileToDocument(respFileName, props) : null; Document expectedDoc; // Test 'schemadata' first, so that if it fails, we will be able to // amend the ref file with the fullest XML response. final String ns = "cxmla"; expectedDoc = (responseDoc != null) ? XmlaSupport.transformSoapXmla( responseDoc, new String[][] {{"content", "schemadata"}}, ns) : null; doTests( requestText, props, testContext, connectString, catalogNameUrls, expectedDoc, XmlaConstants.Content.SchemaData, role, true); if (requestType.equals("EXECUTE")) { return; } expectedDoc = (responseDoc != null) ? XmlaSupport.transformSoapXmla( responseDoc, new String[][] {{"content", "none"}}, ns) : null; doTests( requestText, props, testContext, connectString, catalogNameUrls, expectedDoc, XmlaConstants.Content.None, role, false); expectedDoc = (responseDoc != null) ? XmlaSupport.transformSoapXmla( responseDoc, new String[][] {{"content", "data"}}, ns) : null; doTests( requestText, props, testContext, connectString, catalogNameUrls, expectedDoc, XmlaConstants.Content.Data, role, false); expectedDoc = (responseDoc != null) ? XmlaSupport.transformSoapXmla( responseDoc, new String[][] {{"content", "schema"}}, ns) : null; doTests( requestText, props, testContext, connectString, catalogNameUrls, expectedDoc, XmlaConstants.Content.Schema, role, false); } /** * Executes a SOAP request. * * @param soapRequestText SOAP request * @param props Name/value pairs to substitute in the request * @param testContext Test context * @param connectString Connect string * @param catalogNameUrls Map from catalog names to URL * @param expectedDoc Expected SOAP output * @param content Content type * @param role Role in which to execute query, or null * @param replace Whether to generate a replacement reference log into * TestName.log.xml if there is an exception. If you are running the same * request with different content types and the same reference log, you * should pass {@code true} for the content type that has the most * information (generally * {@link org.olap4j.metadata.XmlaConstants.Content#SchemaData}) * @throws Exception on error */ protected void doTests( String soapRequestText, Properties props, TestContext testContext, String connectString, Map<String, String> catalogNameUrls, Document expectedDoc, XmlaConstants.Content content, Role role, boolean replace) throws Exception { if (content != null) { props.setProperty(XmlaBasicTest.CONTENT_PROP, content.name()); } // Even though it is not used, it is important that entry is in scope // until after request has returned. Prevents role's lock box entry from // being garbage collected. LockBox.Entry entry = null; if (role != null) { final MondrianServer mondrianServer = MondrianServer.forConnection(testContext.getConnection()); entry = mondrianServer.getLockBox().register(role); connectString += "; Role='" + entry.getMoniker() + "'"; props.setProperty(XmlaBaseTestCase.ROLE_PROP, entry.getMoniker()); } soapRequestText = Util.replaceProperties( soapRequestText, Util.toMap(props)); Document soapReqDoc = XmlUtil.parseString(soapRequestText); Document xmlaReqDoc = XmlaSupport.extractBodyFromSoap(soapReqDoc); // do XMLA byte[] bytes = XmlaSupport.processXmla( xmlaReqDoc, filterConnectString(connectString), catalogNameUrls, role, SERVER_CACHE); if (XmlUtil.supportsValidation() // We can't validate against the schema when the content type // is Data because it doesn't respect the XDS. && !content.equals(XmlaConstants.Content.Data)) { // Validating requires a <?xml header. String response = new String(bytes); if (!response.startsWith("<?xml version=\"1.0\"?>")) { response = "<?xml version=\"1.0\"?>" + Util.nl + response; } if (XmlaSupport.validateXmlaUsingXpath(response.getBytes())) { if (DEBUG) { System.out.println( "XmlaBaseTestCase.doTests: XML Data is Valid"); } } } // do SOAP-XMLA String callBackClassName = CallBack.class.getName(); bytes = XmlaSupport.processSoapXmla( soapReqDoc, filterConnectString(connectString), catalogNameUrls, callBackClassName, role, SERVLET_CACHE); if (DEBUG) { System.out.println( "XmlaBaseTestCase.doTests: soap response=" + new String(bytes)); } validate( bytes, expectedDoc, testContext, replace, content.equals(XmlaConstants.Content.Data) ? false : true); Util.discard(entry); } protected void doTestsJson( String soapRequestText, Properties props, TestContext testContext, String connectString, Map<String, String> catalogNameUrls, String expectedStr, XmlaConstants.Content content, Role role) throws Exception { if (content != null) { props.setProperty(XmlaBasicTest.CONTENT_PROP, content.name()); } if (role != null) { final MondrianServer mondrianServer = MondrianServer.forConnection(testContext.getConnection()); final LockBox.Entry entry = mondrianServer.getLockBox().register(role); props.setProperty(XmlaBaseTestCase.ROLE_PROP, entry.getMoniker()); } soapRequestText = Util.replaceProperties( soapRequestText, Util.toMap(props)); Document soapReqDoc = XmlUtil.parseString(soapRequestText); Document xmlaReqDoc = XmlaSupport.extractBodyFromSoap(soapReqDoc); // do XMLA byte[] bytes = XmlaSupport.processXmla( xmlaReqDoc, connectString, catalogNameUrls, role, SERVER_CACHE); // do SOAP-XMLA String callBackClassName = CallBack.class.getName(); bytes = XmlaSupport.processSoapXmla( soapReqDoc, connectString, catalogNameUrls, callBackClassName, role, SERVLET_CACHE); if (DEBUG) { System.out.println( "XmlaBaseTestCase.doTests: soap response=" + new String(bytes)); } String gotStr = new String(bytes); gotStr = ignoreLastUpdateDate(gotStr); gotStr = maskVersion(gotStr); gotStr = testContext.upgradeActual(gotStr); if (expectedStr != null) { // Let DiffRepository do the comparison. It will output // a textual difference, and will update the logfile, // XmlaBasicTest.log.xml. If you agree with the change, // copy this file to XmlaBasicTest.ref.xml. getDiffRepos().assertEquals( "response", "${response}", gotStr); } } enum Action { CREATE, QUERY, CLEAR } /** * Creates, retrieves or clears the session id for this test. * * @param action Action to perform * @return Session id for create, query; null for clear */ protected abstract String getSessionId(Action action); protected static String getSessionId(String name, Action action) { switch (action) { case CLEAR: sessionIdMap.put(name, null); return null; case QUERY: return sessionIdMap.get(name); case CREATE: String sessionId = sessionIdMap.get(name); if (sessionId == null) { int id = sessionIdCounter++; StringBuilder buf = new StringBuilder(); buf.append(name); buf.append("-"); buf.append(id); buf.append("-foo"); sessionId = buf.toString(); sessionIdMap.put(name, sessionId); } return sessionId; default: throw new UnsupportedOperationException(); } } protected static abstract class XmlaRequestCallbackImpl implements XmlaRequestCallback { private static final String MY_SESSION_ID = "my_session_id"; private final String name; protected XmlaRequestCallbackImpl(String name) { this.name = name; } public void init(ServletConfig servletConfig) throws ServletException { } public boolean processHttpHeader( HttpServletRequest request, HttpServletResponse response, Map<String, Object> context) throws Exception { String expect = request.getHeader(XmlaRequestCallback.EXPECT); if ((expect != null) && expect.equalsIgnoreCase( XmlaRequestCallback.EXPECT_100_CONTINUE)) { Helper.generatedExpectResponse( request, response, context); return false; } else { return true; } } public void preAction( HttpServletRequest request, Element[] requestSoapParts, Map<String, Object> context) throws Exception { } private void setSessionId(Map<String, Object> context) { context.put( MY_SESSION_ID, getSessionId(name, Action.CREATE)); } public String generateSessionId(Map<String, Object> context) { setSessionId(context); return (String) context.get(MY_SESSION_ID); } public void postAction( HttpServletRequest request, HttpServletResponse response, byte[][] responseSoapParts, Map<String, Object> context) throws Exception { } } /** * Masks Mondrian's version number from a string. * Note that this method does a mostly blind replacement * of the version string and may replace strings that * just happen to have the same sequence. * * @param str String * @return String with each occurrence of mondrian's version number * (e.g. "2.3.0.0") replaced with "${mondrianVersion}" */ public static String maskVersion(String str) { MondrianServer.MondrianVersion mondrianVersion = MondrianServer.forId(null).getVersion(); String versionString = mondrianVersion.getVersionString(); // regex characters that wouldn't be expected before or after the // version string. This avoids a false match when the version // string digits appear in other contexts (e.g. $3.56) String charsOutOfContext = "([^,\\$\\d])"; String matchString = charsOutOfContext + Pattern.quote(versionString) + charsOutOfContext; return str.replaceAll(matchString, "$1\\${mondrianVersion}$2"); } } // End XmlaBaseTestCase.java