/* (c) 2014 Open Source Geospatial Foundation - all rights reserved * (c) 2001 - 2013 OpenPlans * This code is licensed under the GPL 2.0 license, available at the root * application directory. */ package org.geoserver.test; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import java.awt.Color; import java.awt.image.BufferedImage; import java.awt.image.ColorModel; import java.awt.image.Raster; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.InputStream; import java.io.OutputStream; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import javax.xml.transform.OutputKeys; import javax.xml.transform.Transformer; import javax.xml.transform.TransformerFactory; import javax.xml.transform.dom.DOMSource; import javax.xml.transform.stream.StreamResult; import org.custommonkey.xmlunit.SimpleNamespaceContext; import org.custommonkey.xmlunit.XMLAssert; import org.custommonkey.xmlunit.XMLUnit; import org.custommonkey.xmlunit.XpathEngine; import org.custommonkey.xmlunit.exceptions.XpathException; import org.geoserver.data.test.SystemTestData; import org.geoserver.wfs.WFSInfo; import org.geotools.data.DataAccess; import org.geotools.data.complex.AppSchemaDataAccess; import org.geotools.data.complex.AppSchemaDataAccessRegistry; import org.geotools.data.complex.DataAccessRegistry; import org.geotools.data.complex.FeatureTypeMapping; import org.geotools.data.complex.config.AppSchemaDataAccessConfigurator; import org.geotools.data.jdbc.FilterToSQL; import org.geotools.feature.FeatureCollection; import org.geotools.feature.FeatureIterator; import org.geotools.jdbc.BasicSQLDialect; import org.geotools.jdbc.JDBCDataStore; import org.geotools.jdbc.NestedFilterToSQL; import org.geotools.jdbc.PreparedFilterToSQL; import org.geotools.jdbc.PreparedStatementSQLDialect; import org.geotools.jdbc.SQLDialect; import org.geotools.xml.AppSchemaValidator; import org.geotools.xml.AppSchemaXSDRegistry; import org.geotools.xml.resolver.SchemaCache; import org.geotools.xml.resolver.SchemaCatalog; import org.geotools.xml.resolver.SchemaResolver; import org.opengis.feature.Feature; import org.opengis.feature.simple.SimpleFeatureType; import org.w3c.dom.Document; import org.w3c.dom.NodeList; /** * Abstract base class for WFS (and WMS) test cases that test integration of {@link AppSchemaDataAccess} with * GeoServer. * * <p> * * The implementation takes care to ensure that private {@link XMLUnit} namespace contexts are used * for each mock data instance, to avoid collisions. Use of static {@link XMLAssert} methods risks * collisions in the static namespace context. This class avoids such problems by providing its own * instance methods like those in XMLAssert. * * @author Ben Caradoc-Davies, CSIRO Exploration and Mining */ public abstract class AbstractAppSchemaTestSupport extends GeoServerSystemTestSupport { /** * The namespace URI used internally in the DOM to qualify the name of an "xmlns:" attribute. * Note that "xmlns:" attributes are not accessible via XMLUnit XPathEngine, so testing these * can only be performed by examining the DOM. * * @see <a href="http://www.w3.org/2000/xmlns/">http://www.w3.org/2000/xmlns/</a> */ protected static final String XMLNS = "http://www.w3.org/2000/xmlns/"; /** * WFS namespaces, for use by XMLUnit. A seen in WFSTestSupport, plus xlink. */ @SuppressWarnings("serial") private final Map<String, String> WFS_NAMESPACES = Collections .unmodifiableMap(new HashMap<String, String>() { { put("wfs", "http://www.opengis.net/wfs"); put("ows", "http://www.opengis.net/ows"); put("ogc", "http://www.opengis.net/ogc"); put("xs", "http://www.w3.org/2001/XMLSchema"); put("xsd", "http://www.w3.org/2001/XMLSchema"); put("gml", "http://www.opengis.net/gml"); put("xlink", "http://www.w3.org/1999/xlink"); put("xsi", "http://www.w3.org/2001/XMLSchema-instance"); put("wms", "http://www.opengis.net/wms"); //NC - wms added for wms tests } }); /** * The XpathEngine to be used for this namespace context. */ private XpathEngine xpathEngine; /** * SchemaCatalog to work with AppSchemaValidator for test requests validation. */ private SchemaCatalog catalog; /** * Subclasses override this to construct the test data. * * <p> * * Override to narrow return type and remove checked exception. * * @see org.geoserver.test.GeoServerAbstractTestSupport#buildTestData() */ @Override protected abstract AbstractAppSchemaMockData createTestData(); /** * Configure WFS to encode canonical schema location and use featureMember. * * <p> * * FIXME: These settings should go in wfs.xml for the mock data when tests migrated to new data * directory format. Have to do it programmatically for now. To do this insert in wfs.xml just * after the <tt>featureBounding</tt> setting: * * <ul> * <li><tt><canonicalSchemaLocation>true</canonicalSchemaLocation><tt></li> * <li><tt><encodeFeatureMember>true</encodeFeatureMember><tt></li> * </ul> */ @Override protected void onSetUp(SystemTestData testData) throws Exception { WFSInfo wfs = getGeoServer().getService(WFSInfo.class); wfs.setCanonicalSchemaLocation(true); wfs.setEncodeFeatureMember(true); getGeoServer().save(wfs); // disable schema caching in tests, as schemas are expected to provided on the classpath SchemaCache.disableAutomaticConfiguration(); } /** * Unregister all data access from registry to avoid stale data access being used by other unit * tests. */ @Override protected void onTearDown(SystemTestData testData) throws Exception { DataAccessRegistry.unregisterAndDisposeAll(); AppSchemaDataAccessRegistry.clearAppSchemaProperties(); AppSchemaXSDRegistry.getInstance().dispose(); catalog = null; } /** * Return the test data. * * <p> * * Override to narrow return type. * * @see org.geoserver.test.GeoServerAbstractTestSupport#getTestData() */ @Override public AbstractAppSchemaMockData getTestData() { return (AbstractAppSchemaMockData) super.getTestData(); } /** * Returns the map of namespace prefix to URI configured in the test data. */ public Map<String, String> getNamespaces() { return getTestData().getNamespaces(); } /** * Returns the namespace URI for a given prefix configured in the test data. */ public String getNamespace(String prefix) { return getNamespaces().get(prefix); } /** * Return the response for a GET request for a path (starts with "wfs?"). * * <p> * * Override to remove checked exception. * * @see org.geoserver.test.GeoServerAbstractTestSupport#get(java.lang.String) */ @Override protected InputStream get(String path) { try { return super.get(path); } catch (Exception e) { throw new RuntimeException(e); } } protected InputStream getBinary(String path) { try { return getBinaryInputStream(getAsServletResponse(path)); } catch (Exception e) { throw new RuntimeException(e); } } /** * Return the response for a GET request for a path (starts with "wfs?"). * * <p> * * Override to remove checked exception. * * @see org.geoserver.test.GeoServerAbstractTestSupport#getAsDOM(java.lang.String) */ @Override protected Document getAsDOM(String path) { try { return super.getAsDOM(path); } catch (Exception e) { throw new RuntimeException(e); } } /** * Return the response for a POST request to a path (typically "wfs"). The request XML is a * String. * * <p> * * Override to remove checked exception. * * @see org.geoserver.test.GeoServerAbstractTestSupport#post(java.lang.String, java.lang.String) */ @Override protected InputStream post(String path, String xml) { try { return super.post(path, xml); } catch (Exception e) { throw new RuntimeException(e); } } /** * Return the response for a POST request to a path (typically "wfs"). The request XML is a * String. * * <p> * * Override to remove checked exception. * * @see org.geoserver.test.GeoServerAbstractTestSupport#postAsDOM(java.lang.String, * java.lang.String) */ @Override protected Document postAsDOM(String path, String xml) { try { return super.postAsDOM(path, xml); } catch (Exception e) { throw new RuntimeException(e); } } /** * Return the XpathEngine, configured for this namespace context. * * <p> * * Note that the engine is configured lazily, to ensure that the mock data has been created and * is ready to report data namespaces, which are then put into the namespace context. * * @return configured XpathEngine */ private XpathEngine getXpathEngine() { if (xpathEngine == null) { xpathEngine = XMLUnit.newXpathEngine(); Map<String, String> namespaces = new HashMap<String, String>(); namespaces.putAll(WFS_NAMESPACES); namespaces.putAll(getTestData().getNamespaces()); xpathEngine.setNamespaceContext(new SimpleNamespaceContext(namespaces)); } return xpathEngine; } /** * Return the SchemaCatalog to resolve local schemas. * @return SchemaCatalog */ private SchemaCatalog getSchemaCatalog() { if (catalog == null) { if (testData instanceof AbstractAppSchemaMockData) { catalog = ((AbstractAppSchemaMockData) testData).getSchemaCatalog(); } } return catalog; } /** * Return the flattened value corresponding to an XPath expression from a document. * * @param xpath * XPath expression * @param document * the document under test * @return flattened string value */ protected String evaluate(String xpath, Document document) { try { return getXpathEngine().evaluate(xpath, document); } catch (XpathException e) { throw new RuntimeException(e); } } /** * Return the list of nodes in a document that match an XPath expression. * * @param xpath * XPath expression * @param document * the document under test * @return list of matching nodes */ protected NodeList getMatchingNodes(String xpath, Document document) { try { return getXpathEngine().getMatchingNodes(xpath, document); } catch (XpathException e) { throw new RuntimeException(e); } } /** * Assertion that the flattened value of an XPath expression in document is equal to the * expected value. * * @param expected * expected value of expression * @param xpath * XPath expression * @param document * the document under test */ protected void assertXpathEvaluatesTo(String expected, String xpath, Document document) { assertEquals(expected, evaluate(xpath, document)); } /** * Assert that there are count matches of and XPath expression in a document. * * @param count * expected number of matches * @param xpath * XPath expression * @param document * document under test */ protected void assertXpathCount(int count, String xpath, Document document) { assertEquals(count, getMatchingNodes(xpath, document).getLength()); } /** * Assert that the flattened value of an XPath expression in a document matches a regular * expression. * * @param regex * regular expression that must be matched * @param xpath * XPath expression * @param document * document under test */ protected void assertXpathMatches(String regex, String xpath, Document document) { assertTrue(evaluate(xpath, document).matches(regex)); } /** * Assert that the flattened value of an XPath expression in a document doe not match a regular * expression. * * @param regex * regular expression that must not be matched * @param xpath * XPath expression * @param document * document under test */ protected void assertXpathNotMatches(String regex, String xpath, Document document) { assertFalse(evaluate(xpath, document).matches(regex)); } /** * Return {@link Document} as a pretty-printed string. * * @param document * document to be prettified * @return the prettified string */ protected String prettyString(Document document) { OutputStream output = new ByteArrayOutputStream(); prettyPrint(document, output); return output.toString(); } /** * Pretty-print a {@link Document} to an {@link OutputStream}. * * @param document * document to be prettified * @param output * stream to which output is written */ protected void prettyPrint(Document document, OutputStream output) { try { Transformer tx = TransformerFactory.newInstance().newTransformer(); tx.setOutputProperty(OutputKeys.INDENT, "yes"); tx.transform(new DOMSource(document), new StreamResult(output)); } catch (Exception e) { throw new RuntimeException(e); } } /** * Find the first file matching the supplied path, starting from the supplied root. This doesn't * support multiple matching files. * * @param path * Supplied path * @param root * Directory to start searching from * @return Matching file */ protected File findFile(String path, File root) { File target = null; List<File> files = Arrays.asList(root.listFiles()); String[] steps = path.split("/"); for (int i = 0; i < steps.length; i++) { for (File file : files) { if (file.getName().equals(steps[i])) { if (i < steps.length - 1) { return findFile(path.substring(steps[i].length() + 1, path.length()), file); } else { return file; } } } } return target; } /** * Schema-validate the response for a GET request for a path (starts with "wfs?"). Validation is * against schemas found on the classpath. See * {@link SchemaResolver#getSimpleHttpResourcePath(java.net.URI)} for URL-to-classpath * convention. * * <p> * * If validation fails, a {@link RuntimeException} is thrown with detail containing the failure * messages. The failure messages are also logged. * * @param path * GET request (starts with "wfs?") * @throws RuntimeException * if validation fails */ protected void validateGet(String path) { try { AppSchemaValidator.validate(get(path), getSchemaCatalog()); } catch (RuntimeException e) { LOGGER.severe(e.getMessage()); throw e; } } /** * Schema-validate the response for a POST request to a path (typically "wfs"). Validation is * against schemas found on the classpath. See * {@link SchemaResolver#getSimpleHttpResourcePath(java.net.URI)} for URL-to-classpath * convention. * * <p> * * If validation fails, a {@link RuntimeException} is thrown with detail containing the failure * messages. The failure messages are also logged. * * @param path * request path (typically "wfs") * @param xml * the request XML document * @throws RuntimeException * if validation fails */ protected void validatePost(String path, String xml) { try { AppSchemaValidator.validate(post(path, xml), getSchemaCatalog()); } catch (RuntimeException e) { LOGGER.severe(e.getMessage()); throw e; } } /** * Schema-validate an XML instance document in a string. Validation is against schemas found on * the classpath. See {@link AppSchemaResolver#getSimpleHttpResourcePath(java.net.URI)} for * URL-to-classpath convention. * * <p> * * If validation fails, a {@link RuntimeException} is thrown with detail containing the failure * messages. The failure messages are also logged. * * @param path * request path (typically "wfs") * @param xml * the XML instance document * @throws RuntimeException * if validation fails */ protected void validate(String xml) { try { AppSchemaValidator.validate(xml, getSchemaCatalog()); } catch (RuntimeException e) { LOGGER.severe(e.getMessage()); throw e; } } /** * For WMS tests. * * Asserts that the image is not blank, in the sense that there must be pixels different from * the passed background color. * * @param testName * the name of the test to throw meaningfull messages if something goes wrong * @param image * the imgage to check it is not "blank" * @param bgColor * the background color for which differing pixels are looked for */ protected void assertNotBlank(String testName, BufferedImage image, Color bgColor) { int pixelsDiffer = countNonBlankPixels(testName, image, bgColor); assertTrue(testName + " image is completely blank", 0 < pixelsDiffer); } /** * * For WMS tests. * * * Counts the number of non black pixels * * @param testName * @param image * @param bgColor * */ protected int countNonBlankPixels(String testName, BufferedImage image, Color bgColor) { int pixelsDiffer = 0; for (int y = 0; y < image.getHeight(); y++) { for (int x = 0; x < image.getWidth(); x++) { if (image.getRGB(x, y) != bgColor.getRGB()) { ++pixelsDiffer; } } } LOGGER.fine(testName + ": pixel count=" + (image.getWidth() * image.getHeight()) + " non bg pixels: " + pixelsDiffer); return pixelsDiffer; } /** * Checks the pixel i/j has the specified color * @param image * @param i * @param j * @param color */ protected void assertPixel(BufferedImage image, int i, int j, Color color) { Color actual = getPixelColor(image, i, j); assertEquals(color, actual); } /** * Gets a specific pixel color from the specified buffered image * @param image * @param i * @param j * @param color * */ protected Color getPixelColor(BufferedImage image, int i, int j) { ColorModel cm = image.getColorModel(); Raster raster = image.getRaster(); Object pixel = raster.getDataElements(i, j, null); Color actual; if(cm.hasAlpha()) { actual = new Color(cm.getRed(pixel), cm.getGreen(pixel), cm.getBlue(pixel), cm.getAlpha(pixel)); } else { actual = new Color(cm.getRed(pixel), cm.getGreen(pixel), cm.getBlue(pixel), 255); } return actual; } /** * Checks that the identifiers of the features in the provided collection match the specified ids. * * <p> * Note that: * <ul> * <li>The method considers that feature identifiers follow the convention <code>[type name].[ID]</code> and only matches the ID part.</li> * <li>If the feature collection contains a feature whose identifier does not match any of the passed ids, the check will fail</li> * </ul> * </p> * * @param featureSet the feature collection to check * @param fids the feature identifiers that must be present in the collection */ protected void assertContainsFeatures(FeatureCollection featureSet, String... fids) { List<String> fidList = Arrays.asList(fids); try (FeatureIterator it = featureSet.features()) { int count = 0; while (it.hasNext()) { Feature f = it.next(); String[] parts = f.getIdentifier().getID().split("\\."); String fid = parts[parts.length - 1]; assertTrue(fidList.contains(fid)); count++; } assertEquals(fidList.size(), count); } } /** * Checks that all the pre-conditions for SQL encoding filters on nested attributes are met: * * <ol> * <li>Source datastore is backed by a RDBMS</li> * <li>Joining support is enabled</li> * <li>Nested filters encoding is enabled</li> * </ol> * * <p> * If the method returns <code>false</code> the test should be skipped. * </p> * * @param rootMapping the feature type being queried * @return <code>true</code> if nested filters encoding can be tested, <code>false</code> otherwise. */ protected boolean shouldTestNestedFiltersEncoding(FeatureTypeMapping rootMapping) { if (!(rootMapping.getSource().getDataStore() instanceof JDBCDataStore)) return false; if (!AppSchemaDataAccessConfigurator.isJoining()) return false; if (!AppSchemaDataAccessConfigurator.shouldEncodeNestedFilters()) return false; return true; } /** * Creates a properly configured {@link NestedFilterToSQL} instance to enable testing the SQL encoding of filters on nested attributes. * * <p> * Note: before calling this method, clients should verify that nested filters encoding is enabled by calling * {@link #shouldTestNestedFiltersEncoding(FeatureTypeMapping)}. * </p> * * @param mapping the feature type being queried * @return nested filter encoder */ protected NestedFilterToSQL createNestedFilterEncoder(FeatureTypeMapping mapping) { DataAccess<?, ?> source = mapping.getSource().getDataStore(); if (!(source instanceof JDBCDataStore)) { throw new IllegalArgumentException( "nested filters encoding requires the source datastore be a JDBCDataStore"); } JDBCDataStore store = (JDBCDataStore) source; SQLDialect dialect = store.getSQLDialect(); FilterToSQL original = null; if (dialect instanceof BasicSQLDialect) { original = ((BasicSQLDialect) dialect).createFilterToSQL(); } else if (dialect instanceof PreparedStatementSQLDialect) { original = ((PreparedStatementSQLDialect) dialect).createPreparedFilterToSQL(); // disable prepared statements to have literals actually encoded in the SQL ((PreparedFilterToSQL)original).setPrepareEnabled(false); } original.setFeatureType((SimpleFeatureType) mapping.getSource().getSchema()); NestedFilterToSQL nestedFilterToSQL = new NestedFilterToSQL(mapping, original); nestedFilterToSQL.setInline(true); return nestedFilterToSQL; } }