diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 0fbcf849c..0e879902c 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -22,6 +22,7 @@ This is a minor enhancement and bugfix release. 6. Fix start-openas2.bat for logging directory setting. 7. Add poller configuration to API command for partnership. 8. Change the IOUtil moveFile method to a more intelligent algorithm for non-homogeneous moves. +9. Add DbPartnershipFactory: partnerships can optionally be stored in a database (Azure SQL, PostgreSQL, MySQL, Oracle or the embedded H2) instead of the partnerships XML file. See the commented example in config.xml. The required tables are included in db_ddl.sql and openas2-schema.xml and are only needed when using the database partnership store. ## Upgrade Notes See the openAS2HowTo appendix for the general process on upgrading OpenAS2. diff --git a/Server/src/config/config.xml b/Server/src/config/config.xml index 0a1eea901..5f130388c 100644 --- a/Server/src/config/config.xml +++ b/Server/src/config/config.xml @@ -213,6 +213,20 @@ + attributes = new LinkedHashMap(); + for (int i = 0; i < params.length; i++) { + String param = (String) params[i]; + int pos = param.indexOf('='); + if (i == 0) { + attributes.put("name", param); + } else if (pos == 0) { + return new CommandResult(CommandResult.TYPE_ERROR, "incoming parameter missing name"); + } else if (pos > 0) { + attributes.put(param.substring(0, pos), param.substring(pos + 1)); + } else { + return new CommandResult(CommandResult.TYPE_ERROR, "incoming parameter missing value"); + } + } + + if (partFx instanceof DbPartnershipFactory) { + ((DbPartnershipFactory) partFx).addPartner(attributes); + return new CommandResult(CommandResult.TYPE_OK); + } + if (!(partFx instanceof XMLPartnershipFactory)) { + return new CommandResult(CommandResult.TYPE_COMMAND_NOT_SUPPORTED, "Not supported by current partnership store"); + } DocumentBuilder db = null; try { @@ -50,21 +76,8 @@ public CommandResult execute(PartnershipFactory partFx, Object[] params) throws Element partnerRoot = doc.createElement("partner"); doc.appendChild(partnerRoot); - - for (int i = 0; i < params.length; i++) { - String param = (String) params[i]; - int pos = param.indexOf('='); - if (i == 0) { - partnerRoot.setAttribute("name", param); - } else if (pos == 0) { - return new CommandResult(CommandResult.TYPE_ERROR, "incoming parameter missing name"); - } else if (pos > 0) { - partnerRoot.setAttribute(param.substring(0, pos), param.substring(pos + 1)); - - } else { - return new CommandResult(CommandResult.TYPE_ERROR, "incoming parameter missing value"); - } - + for (Map.Entry attribute : attributes.entrySet()) { + partnerRoot.setAttribute(attribute.getKey(), attribute.getValue()); } ((XMLPartnershipFactory) partFx).loadPartner(partFx.getPartners(), partnerRoot); diff --git a/Server/src/main/java/org/openas2/app/partner/AddPartnershipCommand.java b/Server/src/main/java/org/openas2/app/partner/AddPartnershipCommand.java index 855768223..880fcb023 100644 --- a/Server/src/main/java/org/openas2/app/partner/AddPartnershipCommand.java +++ b/Server/src/main/java/org/openas2/app/partner/AddPartnershipCommand.java @@ -4,6 +4,7 @@ import org.slf4j.LoggerFactory; import org.openas2.OpenAS2Exception; import org.openas2.cmd.CommandResult; +import org.openas2.partner.DbPartnershipFactory; import org.openas2.partner.Partnership; import org.openas2.partner.PartnershipFactory; import org.openas2.partner.XMLPartnershipFactory; @@ -12,6 +13,8 @@ import org.w3c.dom.Document; import org.w3c.dom.Element; +import java.util.LinkedHashMap; +import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -43,6 +46,29 @@ public CommandResult execute(PartnershipFactory partFx, Object[] params) throws } synchronized (partFx) { + if (partFx instanceof DbPartnershipFactory) { + Map attributes = new LinkedHashMap(); + Map pollerConfig = new LinkedHashMap(); + for (int i = 3; i < params.length; i++) { + String param = (String) params[i]; + int equalsPos = param.indexOf('='); + if (equalsPos == 0) { + return new CommandResult(CommandResult.TYPE_ERROR, "incoming parameter missing name"); + } else if (equalsPos < 0) { + return new CommandResult(CommandResult.TYPE_ERROR, "incoming parameter missing value"); + } + if (param.startsWith("pollerConfig.")) { + pollerConfig.put(param.substring("pollerConfig.".length(), equalsPos), param.substring(equalsPos + 1)); + } else { + attributes.put(param.substring(0, equalsPos), param.substring(equalsPos + 1)); + } + } + ((DbPartnershipFactory) partFx).addPartnership((String) params[0], (String) params[1], (String) params[2], attributes, pollerConfig.isEmpty() ? null : pollerConfig); + return new CommandResult(CommandResult.TYPE_OK); + } + if (!(partFx instanceof XMLPartnershipFactory)) { + return new CommandResult(CommandResult.TYPE_COMMAND_NOT_SUPPORTED, "Not supported by current partnership store"); + } Document doc; try { doc = XMLUtil.createDoc(null); diff --git a/Server/src/main/java/org/openas2/app/partner/DeletePartnerCommand.java b/Server/src/main/java/org/openas2/app/partner/DeletePartnerCommand.java index f0000f28a..992d7e7db 100644 --- a/Server/src/main/java/org/openas2/app/partner/DeletePartnerCommand.java +++ b/Server/src/main/java/org/openas2/app/partner/DeletePartnerCommand.java @@ -2,6 +2,7 @@ import org.openas2.OpenAS2Exception; import org.openas2.cmd.CommandResult; +import org.openas2.partner.DbPartnershipFactory; import org.openas2.partner.Partnership; import org.openas2.partner.PartnershipFactory; import org.openas2.partner.XMLPartnershipFactory; @@ -34,6 +35,16 @@ public CommandResult execute(PartnershipFactory partFx, Object[] params) throws synchronized (partFx) { String name = params[0].toString(); + + if (partFx instanceof DbPartnershipFactory) { + try { + ((DbPartnershipFactory) partFx).deletePartner(name); + } catch (OpenAS2Exception e) { + return new CommandResult(CommandResult.TYPE_ERROR, e.getMessage()); + } + return new CommandResult(CommandResult.TYPE_OK); + } + Iterator parts = partFx.getPartners().keySet().iterator(); boolean found = false; diff --git a/Server/src/main/java/org/openas2/app/partner/DeletePartnershipCommand.java b/Server/src/main/java/org/openas2/app/partner/DeletePartnershipCommand.java index 30265eb18..a560f5670 100644 --- a/Server/src/main/java/org/openas2/app/partner/DeletePartnershipCommand.java +++ b/Server/src/main/java/org/openas2/app/partner/DeletePartnershipCommand.java @@ -2,6 +2,7 @@ import org.openas2.OpenAS2Exception; import org.openas2.cmd.CommandResult; +import org.openas2.partner.DbPartnershipFactory; import org.openas2.partner.Partnership; import org.openas2.partner.PartnershipFactory; import org.openas2.partner.XMLPartnershipFactory; @@ -34,6 +35,16 @@ public CommandResult execute(PartnershipFactory partFx, Object[] params) throws synchronized (partFx) { String name = params[0].toString(); + + if (partFx instanceof DbPartnershipFactory) { + try { + ((DbPartnershipFactory) partFx).deletePartnership(name); + } catch (OpenAS2Exception e) { + return new CommandResult(CommandResult.TYPE_ERROR, e.getMessage()); + } + return new CommandResult(CommandResult.TYPE_OK); + } + Iterator parts = partFx.getPartnerships().iterator(); while (parts.hasNext()) { diff --git a/Server/src/main/java/org/openas2/app/partner/StorePartnershipsCommand.java b/Server/src/main/java/org/openas2/app/partner/StorePartnershipsCommand.java index 3cd51f26c..781538854 100644 --- a/Server/src/main/java/org/openas2/app/partner/StorePartnershipsCommand.java +++ b/Server/src/main/java/org/openas2/app/partner/StorePartnershipsCommand.java @@ -3,6 +3,7 @@ import org.openas2.OpenAS2Exception; import org.openas2.cmd.BaseCommand; import org.openas2.cmd.CommandResult; +import org.openas2.partner.DbPartnershipFactory; import org.openas2.partner.PartnershipFactory; import org.openas2.partner.XMLPartnershipFactory; @@ -35,6 +36,9 @@ public CommandResult execute(Object[] params) { return new CommandResult(CommandResult.TYPE_OK, "Stored partnerships"); } + if (partnerFx instanceof DbPartnershipFactory) { + return new CommandResult(CommandResult.TYPE_OK, "The database partnership store persists changes immediately so there is nothing to do"); + } return new CommandResult(CommandResult.TYPE_COMMAND_NOT_SUPPORTED, "Not supported by current partnership store, must be XML"); } } catch (OpenAS2Exception oae) { diff --git a/Server/src/main/java/org/openas2/partner/BasePartnershipFactory.java b/Server/src/main/java/org/openas2/partner/BasePartnershipFactory.java index a3ece2159..5b30f3ea7 100644 --- a/Server/src/main/java/org/openas2/partner/BasePartnershipFactory.java +++ b/Server/src/main/java/org/openas2/partner/BasePartnershipFactory.java @@ -1,5 +1,7 @@ package org.openas2.partner; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.openas2.BaseComponent; import org.openas2.OpenAS2Exception; import org.openas2.message.FileAttribute; @@ -7,6 +9,15 @@ import org.openas2.message.MessageMDN; import org.openas2.params.MessageParameters; import org.openas2.params.ParameterParser; +import org.w3c.dom.Document; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; + +import javax.xml.xpath.XPath; +import javax.xml.xpath.XPathConstants; +import javax.xml.xpath.XPathExpression; +import javax.xml.xpath.XPathExpressionException; +import javax.xml.xpath.XPathFactory; import java.util.ArrayList; import java.util.Iterator; @@ -18,6 +29,8 @@ public abstract class BasePartnershipFactory extends BaseComponent implements PartnershipFactory { private List partnerships; + private Logger baseLogger = LoggerFactory.getLogger(BasePartnershipFactory.class); + public Partnership getPartnership(Partnership p, boolean reverseLookup) throws OpenAS2Exception { Partnership ps = (p.getName() == null) ? null : getPartnership(p.getName()); @@ -144,6 +157,77 @@ protected Partnership getPartnership(String name) throws OpenAS2Exception { return getPartnership(getPartnerships(), name); } + public void replacePartnershipPlaceHolders(Document doc, Partnership partnership) throws OpenAS2Exception { + String xpathExpression = "//*[@*[contains(.,'$partnership.')]]/@*"; + // Create XPathFactory object + XPathFactory xpathFactory = XPathFactory.newInstance(); + // Create XPath object + XPath xpath = xpathFactory.newXPath(); + try { + // Create XPathExpression object + XPathExpression expr = xpath.compile(xpathExpression); + // Evaluate expression result on XML document + NodeList nodes = (NodeList) expr.evaluate(doc, XPathConstants.NODESET); + Pattern PATTERN = Pattern.compile("\\$partnership\\.([^\\$]++)\\$"); + + for (int i = 0; i < nodes.getLength(); i++) { + Node node = nodes.item(i); + String val = node.getNodeValue(); + //logger.debug("Partnership place holder NODE processing: " + val); + StringBuffer strBuf = new StringBuffer(); + Matcher matcher = PATTERN.matcher(val); + boolean hasChanged = false; + while (matcher.find()) { + String value = null; + String[] keys = matcher.group(1).split("\\."); + if (keys.length == 1) { + switch (keys[0]) { + case "name": + value = partnership.getName(); + break; + default: + throw new OpenAS2Exception( + "The partnership placeholder cannot be resolved: " + keys[0] + " in " + val); + } + } else if (keys.length == 2) { + switch (keys[0]) { + case "receiver": + value = partnership.getReceiverID(keys[1]); + break; + case "sender": + value = partnership.getSenderID(keys[1]); + break; + default: + throw new OpenAS2Exception( + "The partnership placeholder cannot be resolved: " + keys[0] + " in " + val); + } + } else { + // don't know how to handle this + throw new OpenAS2Exception( + "The partnership placeholder is invalid and cannot be parsed: " + val); + } + if (value == null) { + throw new OpenAS2Exception( + "Missing attribute value for replacement: " + matcher.group() + " in " + val); + } else { + hasChanged = true; + matcher.appendReplacement(strBuf, Matcher.quoteReplacement(value)); + if (baseLogger.isTraceEnabled()) { + baseLogger.trace("Partnership place holder replaced: " + keys + " :: Replaced with: " + value); + } + } + } + if (hasChanged) { + matcher.appendTail(strBuf); + node.setNodeValue(strBuf.toString()); + } + } + + } catch (XPathExpressionException e) { + e.printStackTrace(); + } + } + // returns true if all values in searchIds match values in partnerIds protected boolean compareMap(Map searchIds, Map partnerIds) { Iterator> searchIt = searchIds.entrySet().iterator(); diff --git a/Server/src/main/java/org/openas2/partner/DbPartnershipFactory.java b/Server/src/main/java/org/openas2/partner/DbPartnershipFactory.java new file mode 100644 index 000000000..c5e2592a2 --- /dev/null +++ b/Server/src/main/java/org/openas2/partner/DbPartnershipFactory.java @@ -0,0 +1,541 @@ +package org.openas2.partner; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.openas2.OpenAS2Exception; +import org.openas2.Session; +import org.openas2.WrappedException; +import org.openas2.XMLSession; +import org.openas2.params.ComponentParameters; +import org.openas2.params.CompositeParameters; +import org.openas2.params.InvalidParameterException; +import org.openas2.params.ParameterParser; +import org.openas2.processor.msgtracking.EmbeddedDBHandler; +import org.openas2.processor.msgtracking.ExternalDBHandler; +import org.openas2.processor.msgtracking.IDBHandler; +import org.openas2.schedule.HasSchedule; +import org.openas2.util.AS2Util; +import org.openas2.util.XMLUtil; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; + +import java.io.IOException; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +/** + * A partnership factory that stores partners and partnerships in a database instead of + * the partnerships XML file. Any database with a JDBC driver is supported (including the + * embedded H2 database and external databases such as Azure SQL, PostgreSQL, MySQL and + * Oracle) using the same connection parameters as the DbTrackingModule. + *

+ * Partnerships can be managed directly in the database by external tooling or through + * the partner/partnership console and REST API commands. The in-memory state is loaded + * at startup, after every change made through this factory, on the "refresh" console + * command and (if the "interval" parameter is set) on a fixed schedule. + *

+ * Table structure (see Server/src/resources/db/openas2-schema.xml and db_ddl.sql): + *

    + *
  • partner - one row per partner with a unique name
  • + *
  • partner_attribute - name/value pairs per partner (as2_id, x509_alias, email ...)
  • + *
  • partnership - one row per partnership linking a sender and receiver partner
  • + *
  • partnership_attribute - name/value pairs per partnership. The category column is + * "attribute" for regular partnership attributes or "sender"/"receiver" for attribute + * overrides applied on top of the linked partner's attributes for this partnership only
  • + *
+ */ +public class DbPartnershipFactory extends BasePartnershipFactory implements RefreshablePartnershipFactory, HasSchedule { + + public static final String PARAM_DB_USER = "db_user"; + public static final String PARAM_DB_PWD = "db_pwd"; + public static final String PARAM_JDBC_CONNECT_STRING = "jdbc_connect_string"; + public static final String PARAM_JDBC_DRIVER = "jdbc_driver"; + public static final String PARAM_FORCE_LOAD_JDBC_DRIVER = "force_load_jdbc_driver"; + public static final String PARAM_USE_EMBEDDED_DB = "use_embedded_db"; + public static final String PARAM_INTERVAL = "interval"; + + public static final String CATEGORY_ATTRIBUTE = "attribute"; + public static final String CATEGORY_SENDER = Partnership.PTYPE_SENDER; + public static final String CATEGORY_RECEIVER = Partnership.PTYPE_RECEIVER; + public static final String CATEGORY_POLLER_CONFIG = Partnership.PCFG_POLLER; + + private IDBHandler dbHandler = null; + + private Map partners = new HashMap(); + + // Set once init() completes so refreshes triggered at runtime restart the partnership + // pollers (at startup Session.start() does that after the whole system is wired up) + private boolean initialized = false; + + private Logger logger = LoggerFactory.getLogger(DbPartnershipFactory.class); + + public Map getPartners() { + return partners; + } + + private int getRefreshInterval() throws InvalidParameterException { + return getParameterInt(PARAM_INTERVAL, false); + } + + public void init(Session session, Map parameters) throws OpenAS2Exception { + super.init(session, parameters); + + CompositeParameters paramParser = new CompositeParameters(true); + paramParser.add("component", new ComponentParameters(this)); + // Support component attributes in connect string + String jdbcConnectString = ParameterParser.parse(getParameter(PARAM_JDBC_CONNECT_STRING, true), paramParser); + + boolean useEmbeddedDB = "true".equals(getParameter(PARAM_USE_EMBEDDED_DB, "false")); + if (!useEmbeddedDB && "true".equals(getParameter(PARAM_FORCE_LOAD_JDBC_DRIVER, "false"))) { + String jdbcDriver = getParameter(PARAM_JDBC_DRIVER, true); + try { + Class.forName(jdbcDriver); + } catch (ClassNotFoundException e) { + throw new OpenAS2Exception("Failed to load JDBC driver for partnership factory: " + jdbcDriver, e); + } + } + if (useEmbeddedDB) { + dbHandler = new EmbeddedDBHandler(); + } else { + dbHandler = new ExternalDBHandler(); + } + dbHandler.start(jdbcConnectString, getParameter(PARAM_DB_USER, true), getParameter(PARAM_DB_PWD, true), getParameters()); + + refresh(); + initialized = true; + } + + public void refresh() throws OpenAS2Exception { + Session session = getSession(); + if (session != null) { + session.destroyPartnershipPollers(Session.PARTNERSHIP_POLLER); + } + try (Connection conn = dbHandler.getConnection()) { + Map> partnersById = loadPartners(conn); + Map newPartners = new HashMap(); + for (Map partner : partnersById.values()) { + String name = partner.get(Partnership.PID_NAME); + if (newPartners.put(name, partner) != null) { + throw new OpenAS2Exception("Partner is defined more than once: " + name); + } + } + List newPartnerships = loadPartnerships(conn, partnersById); + + synchronized (this) { + this.partners = newPartners; + setPartnerships(newPartnerships); + } + if (logger.isInfoEnabled()) { + logger.info("Loaded " + newPartners.size() + " partners and " + newPartnerships.size() + " partnerships from database"); + } + } catch (OpenAS2Exception e) { + throw e; + } catch (Exception e) { + throw new WrappedException(e); + } + // At startup Session.start() starts the pollers once everything is wired up; for + // runtime refreshes the destroy/reload above leaves them stopped so restart them here + if (initialized && session != null) { + session.startPartnershipPollers(); + } + } + + // Keyed by partner ID so partnerships can resolve their sender/receiver rows + private Map> loadPartners(Connection conn) throws Exception { + Map> partnersById = new HashMap>(); + try (PreparedStatement s = conn.prepareStatement("SELECT ID, NAME FROM partner"); + ResultSet rs = s.executeQuery()) { + while (rs.next()) { + Map partner = new HashMap(); + partner.put(Partnership.PID_NAME, rs.getString(2)); + partnersById.put(rs.getLong(1), partner); + } + } + try (PreparedStatement s = conn.prepareStatement("SELECT PARTNER_ID, ATTRIBUTE_NAME, ATTRIBUTE_VALUE FROM partner_attribute"); + ResultSet rs = s.executeQuery()) { + while (rs.next()) { + Map partner = partnersById.get(rs.getLong(1)); + if (partner == null) { + throw new OpenAS2Exception("Partner attribute references unknown partner ID: " + rs.getLong(1)); + } + partner.put(rs.getString(2), rs.getString(3)); + } + } + return partnersById; + } + + private List loadPartnerships(Connection conn, Map> partnersById) throws Exception { + List newPartnerships = new ArrayList(); + Map partnershipsById = new HashMap(); + try (PreparedStatement s = conn.prepareStatement("SELECT ID, NAME, SENDER_PARTNER_ID, RECEIVER_PARTNER_ID FROM partnership"); + ResultSet rs = s.executeQuery()) { + while (rs.next()) { + long id = rs.getLong(1); + String name = rs.getString(2); + if (getPartnership(newPartnerships, name) != null) { + throw new OpenAS2Exception("Partnership is defined more than once: " + name); + } + Partnership partnership = new Partnership(); + partnership.setName(name); + Map sender = partnersById.get(rs.getLong(3)); + if (sender == null) { + throw new OpenAS2Exception("Partnership \"" + name + "\" references an unknown sender partner ID: " + rs.getLong(3)); + } + partnership.getSenderIDs().putAll(sender); + Map receiver = partnersById.get(rs.getLong(4)); + if (receiver == null) { + throw new OpenAS2Exception("Partnership \"" + name + "\" references an unknown receiver partner ID: " + rs.getLong(4)); + } + partnership.getReceiverIDs().putAll(receiver); + partnershipsById.put(id, partnership); + newPartnerships.add(partnership); + } + } + // Partnership attributes: plain attributes, per-partnership sender/receiver overrides + // and partnership poller configurations + Map> attributesById = new HashMap>(); + Map> pollerConfigsById = new HashMap>(); + try (PreparedStatement s = conn.prepareStatement("SELECT PARTNERSHIP_ID, CATEGORY, ATTRIBUTE_NAME, ATTRIBUTE_VALUE FROM partnership_attribute"); + ResultSet rs = s.executeQuery()) { + while (rs.next()) { + long partnershipId = rs.getLong(1); + Partnership partnership = partnershipsById.get(partnershipId); + if (partnership == null) { + throw new OpenAS2Exception("Partnership attribute references unknown partnership ID: " + partnershipId); + } + String category = rs.getString(2); + String attrName = rs.getString(3); + String attrValue = rs.getString(4); + if (CATEGORY_SENDER.equalsIgnoreCase(category)) { + partnership.getSenderIDs().put(attrName, attrValue); + } else if (CATEGORY_RECEIVER.equalsIgnoreCase(category)) { + partnership.getReceiverIDs().put(attrName, attrValue); + } else if (CATEGORY_ATTRIBUTE.equalsIgnoreCase(category)) { + Map attributes = attributesById.get(partnershipId); + if (attributes == null) { + attributes = new HashMap(); + attributesById.put(partnershipId, attributes); + } + attributes.put(attrName, attrValue); + } else if (CATEGORY_POLLER_CONFIG.equalsIgnoreCase(category)) { + Map pollerConfig = pollerConfigsById.get(partnershipId); + if (pollerConfig == null) { + pollerConfig = new HashMap(); + pollerConfigsById.put(partnershipId, pollerConfig); + } + pollerConfig.put(attrName, attrValue); + } else { + throw new OpenAS2Exception("Unsupported partnership attribute category \"" + category + "\" for partnership: " + partnership.getName()); + } + } + } + for (Map.Entry> entry : attributesById.entrySet()) { + Partnership partnership = partnershipsById.get(entry.getKey()); + Map attributes = entry.getValue(); + AS2Util.attributeEnhancer(attributes); + partnership.getAttributes().putAll(attributes); + } + for (Partnership partnership : newPartnerships) { + if ("true".equalsIgnoreCase(partnership.getAttributeOrProperty(Partnership.PA_USE_DYNAMIC_CONTENT_TYPE_MAPPING, "false"))) { + try { + partnership.setUseDynamicContentTypeLookup(true); + } catch (IOException e) { + logger.error("Error setting up dynamic Content-Type lookup: " + e.getMessage(), e); + throw new OpenAS2Exception("Partnership failed to be set up correctly for dynamic Content-Type lookup: " + partnership.getName()); + } + } + } + for (Map.Entry> entry : pollerConfigsById.entrySet()) { + Partnership partnership = partnershipsById.get(entry.getKey()); + launchPartnershipPoller(partnership, entry.getValue()); + // Surface the poller config in the view/list command output the same way the + // view command renders the pollerConfig element of the XML partnerships file + entry.getValue().forEach((key, value) -> partnership.getAttributes().put("pollerConfig." + key, value)); + } + return newPartnerships; + } + + /** + * Registers a directory poller for a partnership by merging its "pollerConfig" category + * attributes over the base poller config from config.xml, mirroring the pollerConfig + * element support of the XML partnerships file. + */ + private void launchPartnershipPoller(Partnership partnership, Map pollerConfig) throws OpenAS2Exception { + if (!"true".equalsIgnoreCase(pollerConfig.get("enabled"))) { + return; + } + Session session = getSession(); + if (!(session instanceof XMLSession)) { + logger.warn("Cannot launch a partnership poller without a session for partnership: " + partnership.getName()); + return; + } + if (logger.isTraceEnabled()) { + logger.trace("Found partnership poller for partnership: " + partnership.getName()); + } + Node basePollerConfigNode = ((XMLSession) session).getBasePartnershipPollerConfig(); + if (basePollerConfigNode == null) { + throw new OpenAS2Exception("Missing base poller config node in config.xml to configure partnership poller."); + } + Document pollerDoc; + try { + pollerDoc = XMLUtil.createDoc(basePollerConfigNode); + } catch (Exception e) { + throw new OpenAS2Exception("Failed to create a poller document: " + e.getMessage(), e); + } + Element pollerConfigElem = pollerDoc.getDocumentElement(); + // Merge the attributes from the base config with the partnership specific ones + Map attributes = XMLUtil.mapAttributes(pollerConfigElem); + attributes.putAll(pollerConfig); + // Enhance the attribute values in case they are using dynamic variables + AS2Util.attributeEnhancer(attributes); + attributes.forEach((key, value) -> { + pollerConfigElem.setAttribute(key, value); + }); + // replace the $partnership.* placeholders + replacePartnershipPlaceHolders(pollerDoc, partnership); + session.loadPartnershipPoller(pollerConfigElem, partnership.getName(), Session.PARTNERSHIP_POLLER); + } + + /** + * Adds a partner to the database and reloads the in-memory partnership state. + * + * @param attributes - partner attributes including the mandatory "name" attribute + * @throws OpenAS2Exception if the partner has no name or already exists + */ + public synchronized void addPartner(Map attributes) throws OpenAS2Exception { + String name = attributes.get(Partnership.PID_NAME); + if (name == null || name.trim().length() == 0) { + throw new OpenAS2Exception("Partner must have a name"); + } + try (Connection conn = dbHandler.getConnection()) { + conn.setAutoCommit(false); + try { + if (findId(conn, "SELECT ID FROM partner WHERE NAME = ?", name) != null) { + throw new OpenAS2Exception("Partner is defined more than once: " + name); + } + try (PreparedStatement s = conn.prepareStatement("INSERT INTO partner (NAME) VALUES (?)")) { + s.setString(1, name); + s.executeUpdate(); + } + long partnerId = findId(conn, "SELECT ID FROM partner WHERE NAME = ?", name); + try (PreparedStatement s = conn.prepareStatement("INSERT INTO partner_attribute (PARTNER_ID, ATTRIBUTE_NAME, ATTRIBUTE_VALUE) VALUES (?, ?, ?)")) { + for (Map.Entry attribute : attributes.entrySet()) { + if (Partnership.PID_NAME.equals(attribute.getKey())) { + continue; + } + s.setLong(1, partnerId); + s.setString(2, attribute.getKey()); + s.setString(3, attribute.getValue()); + s.executeUpdate(); + } + } + conn.commit(); + } catch (Exception e) { + conn.rollback(); + throw e; + } + } catch (OpenAS2Exception e) { + throw e; + } catch (Exception e) { + throw new WrappedException(e); + } + refresh(); + } + + /** + * Deletes a partner from the database and reloads the in-memory partnership state. + * + * @param name - the name of the partner to delete + * @throws OpenAS2Exception if the partner does not exist or is referenced by a partnership + */ + public synchronized void deletePartner(String name) throws OpenAS2Exception { + try (Connection conn = dbHandler.getConnection()) { + conn.setAutoCommit(false); + try { + Long id = findId(conn, "SELECT ID FROM partner WHERE NAME = ?", name); + if (id == null) { + throw new OpenAS2Exception("Unknown partner name: " + name); + } + try (PreparedStatement s = conn.prepareStatement("SELECT COUNT(*) FROM partnership WHERE SENDER_PARTNER_ID = ? OR RECEIVER_PARTNER_ID = ?")) { + s.setLong(1, id); + s.setLong(2, id); + try (ResultSet rs = s.executeQuery()) { + rs.next(); + if (rs.getLong(1) > 0) { + throw new OpenAS2Exception("Cannot delete partner; it is tied to some partnerships"); + } + } + } + executeUpdate(conn, "DELETE FROM partner_attribute WHERE PARTNER_ID = ?", id); + executeUpdate(conn, "DELETE FROM partner WHERE ID = ?", id); + conn.commit(); + } catch (Exception e) { + conn.rollback(); + throw e; + } + } catch (OpenAS2Exception e) { + throw e; + } catch (Exception e) { + throw new WrappedException(e); + } + refresh(); + } + + /** + * Adds a partnership between two existing partners to the database and reloads the + * in-memory partnership state. + * + * @param name - unique name of the partnership + * @param senderName - name of an existing partner acting as the sender + * @param receiverName - name of an existing partner acting as the receiver + * @param attributes - partnership attributes (stored with the "attribute" category) + * @throws OpenAS2Exception if the partnership already exists or either partner is unknown + */ + public synchronized void addPartnership(String name, String senderName, String receiverName, Map attributes) throws OpenAS2Exception { + addPartnership(name, senderName, receiverName, attributes, null); + } + + /** + * Adds a partnership between two existing partners to the database and reloads the + * in-memory partnership state. + * + * @param name - unique name of the partnership + * @param senderName - name of an existing partner acting as the sender + * @param receiverName - name of an existing partner acting as the receiver + * @param attributes - partnership attributes (stored with the "attribute" category) + * @param pollerConfig - directory poller attributes for this partnership (stored with the + * "pollerConfig" category), or null if the partnership has no poller + * @throws OpenAS2Exception if the partnership already exists or either partner is unknown + */ + public synchronized void addPartnership(String name, String senderName, String receiverName, Map attributes, Map pollerConfig) throws OpenAS2Exception { + try (Connection conn = dbHandler.getConnection()) { + conn.setAutoCommit(false); + try { + if (findId(conn, "SELECT ID FROM partnership WHERE NAME = ?", name) != null) { + throw new OpenAS2Exception("Partnership is defined more than once: " + name); + } + Long senderId = findId(conn, "SELECT ID FROM partner WHERE NAME = ?", senderName); + if (senderId == null) { + throw new OpenAS2Exception("Partnership " + name + " has an undefined " + Partnership.PTYPE_SENDER + ": " + senderName); + } + Long receiverId = findId(conn, "SELECT ID FROM partner WHERE NAME = ?", receiverName); + if (receiverId == null) { + throw new OpenAS2Exception("Partnership " + name + " has an undefined " + Partnership.PTYPE_RECEIVER + ": " + receiverName); + } + try (PreparedStatement s = conn.prepareStatement("INSERT INTO partnership (NAME, SENDER_PARTNER_ID, RECEIVER_PARTNER_ID) VALUES (?, ?, ?)")) { + s.setString(1, name); + s.setLong(2, senderId); + s.setLong(3, receiverId); + s.executeUpdate(); + } + long partnershipId = findId(conn, "SELECT ID FROM partnership WHERE NAME = ?", name); + try (PreparedStatement s = conn.prepareStatement("INSERT INTO partnership_attribute (PARTNERSHIP_ID, CATEGORY, ATTRIBUTE_NAME, ATTRIBUTE_VALUE) VALUES (?, ?, ?, ?)")) { + for (Map.Entry attribute : attributes.entrySet()) { + s.setLong(1, partnershipId); + s.setString(2, CATEGORY_ATTRIBUTE); + s.setString(3, attribute.getKey()); + s.setString(4, attribute.getValue()); + s.executeUpdate(); + } + if (pollerConfig != null) { + for (Map.Entry attribute : pollerConfig.entrySet()) { + s.setLong(1, partnershipId); + s.setString(2, CATEGORY_POLLER_CONFIG); + s.setString(3, attribute.getKey()); + s.setString(4, attribute.getValue()); + s.executeUpdate(); + } + } + } + conn.commit(); + } catch (Exception e) { + conn.rollback(); + throw e; + } + } catch (OpenAS2Exception e) { + throw e; + } catch (Exception e) { + throw new WrappedException(e); + } + refresh(); + } + + /** + * Deletes a partnership from the database and reloads the in-memory partnership state. + * + * @param name - the name of the partnership to delete + * @throws OpenAS2Exception if the partnership does not exist + */ + public synchronized void deletePartnership(String name) throws OpenAS2Exception { + try (Connection conn = dbHandler.getConnection()) { + conn.setAutoCommit(false); + try { + Long id = findId(conn, "SELECT ID FROM partnership WHERE NAME = ?", name); + if (id == null) { + throw new OpenAS2Exception("Partnership not found: " + name); + } + executeUpdate(conn, "DELETE FROM partnership_attribute WHERE PARTNERSHIP_ID = ?", id); + executeUpdate(conn, "DELETE FROM partnership WHERE ID = ?", id); + conn.commit(); + } catch (Exception e) { + conn.rollback(); + throw e; + } + } catch (OpenAS2Exception e) { + throw e; + } catch (Exception e) { + throw new WrappedException(e); + } + refresh(); + } + + private Long findId(Connection conn, String sql, String name) throws Exception { + try (PreparedStatement s = conn.prepareStatement(sql)) { + s.setString(1, name); + try (ResultSet rs = s.executeQuery()) { + return rs.next() ? rs.getLong(1) : null; + } + } + } + + private void executeUpdate(Connection conn, String sql, long id) throws Exception { + try (PreparedStatement s = conn.prepareStatement(sql)) { + s.setLong(1, id); + s.executeUpdate(); + } + } + + @Override + public void schedule(ScheduledExecutorService executor) throws OpenAS2Exception { + int interval = getRefreshInterval(); + if (interval <= 0) { + return; + } + executor.scheduleWithFixedDelay(new Runnable() { + @Override + public void run() { + try { + refresh(); + } catch (Exception e) { + logger.error("Failed to refresh partnerships from database: " + e.getMessage(), e); + } + } + }, interval, interval, TimeUnit.SECONDS); + } + + @Override + public void destroy() throws Exception { + if (dbHandler != null) { + dbHandler.stop(); + dbHandler = null; + } + } +} diff --git a/Server/src/main/java/org/openas2/partner/XMLPartnershipFactory.java b/Server/src/main/java/org/openas2/partner/XMLPartnershipFactory.java index 6e588c5b2..12a0c2986 100644 --- a/Server/src/main/java/org/openas2/partner/XMLPartnershipFactory.java +++ b/Server/src/main/java/org/openas2/partner/XMLPartnershipFactory.java @@ -356,75 +356,4 @@ public Document getPartnershipsXml() { public void setPartnershipsXml(Document partnershipsXml) { this.partnershipsXml = partnershipsXml; } - - public void replacePartnershipPlaceHolders(Document doc, Partnership partnership) throws OpenAS2Exception { - String xpathExpression = "//*[@*[contains(.,'$partnership.')]]/@*"; - // Create XPathFactory object - XPathFactory xpathFactory = XPathFactory.newInstance(); - // Create XPath object - XPath xpath = xpathFactory.newXPath(); - try { - // Create XPathExpression object - XPathExpression expr = xpath.compile(xpathExpression); - // Evaluate expression result on XML document - NodeList nodes = (NodeList) expr.evaluate(doc, XPathConstants.NODESET); - Pattern PATTERN = Pattern.compile("\\$partnership\\.([^\\$]++)\\$"); - - for (int i = 0; i < nodes.getLength(); i++) { - Node node = nodes.item(i); - String val = node.getNodeValue(); - //logger.debug("Partnership place holder NODE processing: " + val); - StringBuffer strBuf = new StringBuffer(); - Matcher matcher = PATTERN.matcher(val); - boolean hasChanged = false; - while (matcher.find()) { - String value = null; - String[] keys = matcher.group(1).split("\\."); - if (keys.length == 1) { - switch (keys[0]) { - case "name": - value = partnership.getName(); - break; - default: - throw new OpenAS2Exception( - "The partnership placeholder cannot be resolved: " + keys[0] + " in " + val); - } - } else if (keys.length == 2) { - switch (keys[0]) { - case "receiver": - value = partnership.getReceiverID(keys[1]); - break; - case "sender": - value = partnership.getSenderID(keys[1]); - break; - default: - throw new OpenAS2Exception( - "The partnership placeholder cannot be resolved: " + keys[0] + " in " + val); - } - } else { - // don't know how to handle this - throw new OpenAS2Exception( - "The partnership placeholder is invalid and cannot be parsed: " + val); - } - if (value == null) { - throw new OpenAS2Exception( - "Missing attribute value for replacement: " + matcher.group() + " in " + val); - } else { - hasChanged = true; - matcher.appendReplacement(strBuf, Matcher.quoteReplacement(value)); - if (logger.isTraceEnabled()) { - logger.trace("Partnership place holder replaced: " + keys + " :: Replaced with: " + value); - } - } - } - if (hasChanged) { - matcher.appendTail(strBuf); - node.setNodeValue(strBuf.toString()); - } - } - - } catch (XPathExpressionException e) { - e.printStackTrace(); - } - } } diff --git a/Server/src/main/java/org/openas2/processor/msgtracking/EmbeddedDBHandler.java b/Server/src/main/java/org/openas2/processor/msgtracking/EmbeddedDBHandler.java index dfeabbde4..a98b81b38 100644 --- a/Server/src/main/java/org/openas2/processor/msgtracking/EmbeddedDBHandler.java +++ b/Server/src/main/java/org/openas2/processor/msgtracking/EmbeddedDBHandler.java @@ -19,7 +19,7 @@ import java.util.Map; import java.util.concurrent.TimeUnit; -class EmbeddedDBHandler extends DbTrackingModule implements IDBHandler { +public class EmbeddedDBHandler extends DbTrackingModule implements IDBHandler { @Nullable private JdbcConnectionPool cp = null; diff --git a/Server/src/main/java/org/openas2/processor/msgtracking/ExternalDBHandler.java b/Server/src/main/java/org/openas2/processor/msgtracking/ExternalDBHandler.java index 40bde906b..26fefa78e 100644 --- a/Server/src/main/java/org/openas2/processor/msgtracking/ExternalDBHandler.java +++ b/Server/src/main/java/org/openas2/processor/msgtracking/ExternalDBHandler.java @@ -23,7 +23,7 @@ import java.util.Enumeration; import java.util.Map; -class ExternalDBHandler extends DbTrackingModule implements IDBHandler { +public class ExternalDBHandler extends DbTrackingModule implements IDBHandler { @Nullable private HikariDataSource ds = null; diff --git a/Server/src/main/java/org/openas2/processor/msgtracking/IDBHandler.java b/Server/src/main/java/org/openas2/processor/msgtracking/IDBHandler.java index da8d23b8f..ed717b543 100644 --- a/Server/src/main/java/org/openas2/processor/msgtracking/IDBHandler.java +++ b/Server/src/main/java/org/openas2/processor/msgtracking/IDBHandler.java @@ -9,7 +9,7 @@ import java.sql.SQLException; import java.util.Map; -interface IDBHandler { +public interface IDBHandler { void createConnectionPool(String connectString, String userName, String pwd) throws OpenAS2Exception; diff --git a/Server/src/resources/db/openas2-schema.xml b/Server/src/resources/db/openas2-schema.xml index d4a9539d6..1a00a9e83 100644 --- a/Server/src/resources/db/openas2-schema.xml +++ b/Server/src/resources/db/openas2-schema.xml @@ -30,4 +30,73 @@ + + + + + + + + + +
+ + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + +
diff --git a/Server/src/test/java/org/openas2/app/partner/PartnerCommandsWithDbFactoryTest.java b/Server/src/test/java/org/openas2/app/partner/PartnerCommandsWithDbFactoryTest.java new file mode 100644 index 000000000..c9f7bca07 --- /dev/null +++ b/Server/src/test/java/org/openas2/app/partner/PartnerCommandsWithDbFactoryTest.java @@ -0,0 +1,120 @@ +package org.openas2.app.partner; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestInstance.Lifecycle; +import org.openas2.cmd.CommandResult; +import org.openas2.partner.DbPartnershipFactory; +import org.openas2.partner.Partnership; + +import java.nio.file.Files; +import java.nio.file.Paths; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * Verifies that the partner/partnership console and REST API commands manage entries + * in the database when the session uses a DbPartnershipFactory. + */ +@TestInstance(Lifecycle.PER_CLASS) +public class PartnerCommandsWithDbFactoryTest { + + private static final String CONNECT_STRING = "jdbc:h2:mem:db_partnership_commands_test;DB_CLOSE_DELAY=-1"; + + private DbPartnershipFactory factory; + // Keeps the in-memory database alive independently of the factory's connection pool + private Connection seedConn; + + @BeforeAll + public void setUp() throws Exception { + seedConn = DriverManager.getConnection(CONNECT_STRING, "sa", ""); + try (Statement s = seedConn.createStatement()) { + String ddl = new String(Files.readAllBytes(Paths.get("src", "config", "db_ddl.sql"))); + for (String statement : ddl.split(";")) { + if (!statement.trim().isEmpty()) { + s.execute(statement); + } + } + } + + factory = new DbPartnershipFactory(); + Map params = new HashMap(); + params.put(DbPartnershipFactory.PARAM_USE_EMBEDDED_DB, "true"); + params.put("tcp_server_start", "false"); + params.put(DbPartnershipFactory.PARAM_DB_USER, "sa"); + params.put(DbPartnershipFactory.PARAM_DB_PWD, ""); + params.put(DbPartnershipFactory.PARAM_JDBC_CONNECT_STRING, CONNECT_STRING); + factory.init(null, params); + } + + @AfterAll + public void tearDown() throws Exception { + seedConn.close(); + factory.destroy(); + } + + private long countRows(String table) throws Exception { + try (Statement s = seedConn.createStatement(); + ResultSet rs = s.executeQuery("SELECT COUNT(*) FROM " + table)) { + rs.next(); + return rs.getLong(1); + } + } + + @Test + public void commandsManagePartnersAndPartnershipsInTheDatabase() throws Exception { + CommandResult result = new AddPartnerCommand().execute(factory, new Object[]{"PartnerA", "as2_id=A_ID", "x509_alias=partnera"}); + assertEquals(CommandResult.TYPE_OK, result.getType()); + result = new AddPartnerCommand().execute(factory, new Object[]{"PartnerB", "as2_id=B_ID"}); + assertEquals(CommandResult.TYPE_OK, result.getType()); + assertEquals(2, countRows("partner")); + + result = new AddPartnershipCommand().execute(factory, new Object[]{"A-to-B", "PartnerA", "PartnerB", "protocol=as2", "subject=Test"}); + assertEquals(CommandResult.TYPE_OK, result.getType()); + assertEquals(1, countRows("partnership")); + + Partnership search = new Partnership(); + search.setName("A-to-B"); + Partnership ps = factory.getPartnership(search, false); + assertNotNull(ps); + assertEquals("A_ID", ps.getSenderID("as2_id")); + assertEquals("B_ID", ps.getReceiverID("as2_id")); + assertEquals("as2", ps.getAttribute("protocol")); + + // pollerConfig.* params are stored under the pollerConfig category + result = new AddPartnershipCommand().execute(factory, new Object[]{"other", "PartnerA", "PartnerB", "pollerConfig.enabled=false", "pollerConfig.interval=30"}); + assertEquals(CommandResult.TYPE_OK, result.getType()); + try (Statement s = seedConn.createStatement(); + ResultSet rs = s.executeQuery("SELECT COUNT(*) FROM partnership_attribute WHERE CATEGORY = 'pollerConfig'")) { + rs.next(); + assertEquals(2, rs.getLong(1)); + } + result = new DeletePartnershipCommand().execute(factory, new Object[]{"other"}); + assertEquals(CommandResult.TYPE_OK, result.getType()); + + // A partner referenced by a partnership cannot be deleted + result = new DeletePartnerCommand().execute(factory, new Object[]{"PartnerA"}); + assertEquals(CommandResult.TYPE_ERROR, result.getType()); + + result = new DeletePartnershipCommand().execute(factory, new Object[]{"A-to-B"}); + assertEquals(CommandResult.TYPE_OK, result.getType()); + assertEquals(0, countRows("partnership")); + assertEquals(0, countRows("partnership_attribute")); + + result = new DeletePartnerCommand().execute(factory, new Object[]{"PartnerA"}); + assertEquals(CommandResult.TYPE_OK, result.getType()); + assertEquals(1, countRows("partner")); + + result = new DeletePartnershipCommand().execute(factory, new Object[]{"A-to-B"}); + assertEquals(CommandResult.TYPE_ERROR, result.getType()); + } +} diff --git a/Server/src/test/java/org/openas2/partner/DbPartnershipFactoryTest.java b/Server/src/test/java/org/openas2/partner/DbPartnershipFactoryTest.java new file mode 100644 index 000000000..cdaed8b42 --- /dev/null +++ b/Server/src/test/java/org/openas2/partner/DbPartnershipFactoryTest.java @@ -0,0 +1,230 @@ +package org.openas2.partner; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestInstance.Lifecycle; + +import java.nio.file.Files; +import java.nio.file.Paths; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.Statement; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Verifies that DbPartnershipFactory loads partners and partnerships from the database + * tables with the same semantics as XMLPartnershipFactory: partner attributes feed the + * sender/receiver ID maps, per-partnership sender/receiver rows override them and + * "attribute" category rows become partnership attributes. + */ +@TestInstance(Lifecycle.PER_CLASS) +public class DbPartnershipFactoryTest { + + private static final String CONNECT_STRING = "jdbc:h2:mem:db_partnership_factory_test;DB_CLOSE_DELAY=-1"; + + private DbPartnershipFactory factory; + // Keeps the in-memory database alive independently of the factory's connection pool + private Connection seedConn; + + @BeforeAll + public void setUp() throws Exception { + seedConn = DriverManager.getConnection(CONNECT_STRING, "sa", ""); + try (Statement s = seedConn.createStatement()) { + // Apply the same schema used in production so the test exercises the real table shapes + String ddl = new String(Files.readAllBytes(Paths.get("src", "config", "db_ddl.sql"))); + for (String statement : ddl.split(";")) { + if (!statement.trim().isEmpty()) { + s.execute(statement); + } + } + s.executeUpdate("INSERT INTO partner (ID, NAME) VALUES (1, 'OpenAS2A')"); + s.executeUpdate("INSERT INTO partner (ID, NAME) VALUES (2, 'OpenAS2B')"); + s.executeUpdate(partnerAttr(1, "as2_id", "OpenAS2A_ID")); + s.executeUpdate(partnerAttr(1, "x509_alias", "openas2a")); + s.executeUpdate(partnerAttr(1, "email", "partnerA@example.com")); + s.executeUpdate(partnerAttr(2, "as2_id", "OpenAS2B_ID")); + s.executeUpdate(partnerAttr(2, "x509_alias", "openas2b")); + + s.executeUpdate("INSERT INTO partnership (ID, NAME, SENDER_PARTNER_ID, RECEIVER_PARTNER_ID) VALUES (1, 'OpenAS2A-to-OpenAS2B', 1, 2)"); + s.executeUpdate(partnershipAttr(1, "attribute", "protocol", "as2")); + s.executeUpdate(partnershipAttr(1, "attribute", "subject", "From OpenAS2A to OpenAS2B")); + // Sender override: this partnership signs with a different certificate alias + s.executeUpdate(partnershipAttr(1, "sender", "x509_alias", "openas2a_special")); + + s.executeUpdate("INSERT INTO partnership (ID, NAME, SENDER_PARTNER_ID, RECEIVER_PARTNER_ID) VALUES (2, 'OpenAS2B-to-OpenAS2A', 2, 1)"); + s.executeUpdate(partnershipAttr(2, "attribute", "protocol", "as2")); + // Exercise the pollerConfig category parse path (no poller is launched: not enabled) + s.executeUpdate(partnershipAttr(2, "pollerConfig", "enabled", "false")); + // Seeding with explicit IDs does not advance the identity sequences; move them + // past the seeded rows so the write tests can insert without colliding + s.execute("ALTER TABLE partner ALTER COLUMN ID RESTART WITH 100"); + s.execute("ALTER TABLE partnership ALTER COLUMN ID RESTART WITH 100"); + } + + factory = new DbPartnershipFactory(); + Map params = new HashMap(); + params.put(DbPartnershipFactory.PARAM_USE_EMBEDDED_DB, "true"); + params.put("tcp_server_start", "false"); + params.put(DbPartnershipFactory.PARAM_DB_USER, "sa"); + params.put(DbPartnershipFactory.PARAM_DB_PWD, ""); + params.put(DbPartnershipFactory.PARAM_JDBC_CONNECT_STRING, CONNECT_STRING); + factory.init(null, params); + } + + private String partnerAttr(int partnerId, String name, String value) { + return "INSERT INTO partner_attribute (PARTNER_ID, ATTRIBUTE_NAME, ATTRIBUTE_VALUE) VALUES (" + partnerId + ", '" + name + "', '" + value + "')"; + } + + private String partnershipAttr(int partnershipId, String category, String name, String value) { + return "INSERT INTO partnership_attribute (PARTNERSHIP_ID, CATEGORY, ATTRIBUTE_NAME, ATTRIBUTE_VALUE) VALUES (" + partnershipId + ", '" + category + "', '" + name + "', '" + value + "')"; + } + + @AfterAll + public void tearDown() throws Exception { + seedConn.close(); + factory.destroy(); + } + + @Test + public void loadsAllPartnersAndPartnerships() { + assertEquals(2, factory.getPartnerships().size()); + assertEquals(2, factory.getPartners().size()); + @SuppressWarnings("unchecked") + Map partnerA = (Map) factory.getPartners().get("OpenAS2A"); + assertNotNull(partnerA); + assertEquals("OpenAS2A_ID", partnerA.get("as2_id")); + } + + @Test + public void findsPartnershipByName() throws Exception { + Partnership search = new Partnership(); + search.setName("OpenAS2A-to-OpenAS2B"); + Partnership ps = factory.getPartnership(search, false); + + assertEquals("OpenAS2A_ID", ps.getSenderID("as2_id")); + assertEquals("OpenAS2B_ID", ps.getReceiverID("as2_id")); + } + + @Test + public void findsPartnershipBySenderAndReceiverIds() { + Map senderIDs = new HashMap(); + senderIDs.put("as2_id", "OpenAS2B_ID"); + Map receiverIDs = new HashMap(); + receiverIDs.put("as2_id", "OpenAS2A_ID"); + + Partnership ps = factory.getPartnership(senderIDs, receiverIDs); + + assertNotNull(ps); + assertEquals("OpenAS2B-to-OpenAS2A", ps.getName()); + } + + @Test + public void partnerAttributesFlowIntoPartnershipAndOverridesApply() throws Exception { + Partnership search = new Partnership(); + search.setName("OpenAS2A-to-OpenAS2B"); + Partnership ps = factory.getPartnership(search, false); + + // Partner-level attribute inherited by the partnership + assertEquals("partnerA@example.com", ps.getSenderID("email")); + // Sender-category attribute overrides the partner-level value for this partnership only + assertEquals("openas2a_special", ps.getSenderID("x509_alias")); + // The other partnership using the same partner keeps the partner-level value + Partnership reverseSearch = new Partnership(); + reverseSearch.setName("OpenAS2B-to-OpenAS2A"); + Partnership reverse = factory.getPartnership(reverseSearch, false); + assertEquals("openas2a", reverse.getReceiverID("x509_alias")); + } + + @Test + public void pollerConfigRowsSurfaceAsPrefixedAttributes() throws Exception { + Partnership search = new Partnership(); + search.setName("OpenAS2B-to-OpenAS2A"); + Partnership ps = factory.getPartnership(search, false); + + assertEquals("false", ps.getAttribute("pollerConfig.enabled")); + } + + @Test + public void loadsPartnershipAttributes() throws Exception { + Partnership search = new Partnership(); + search.setName("OpenAS2A-to-OpenAS2B"); + Partnership ps = factory.getPartnership(search, false); + + assertEquals("as2", ps.getAttribute("protocol")); + assertEquals("From OpenAS2A to OpenAS2B", ps.getAttribute("subject")); + } + + @Test + public void unknownPartnershipThrows() { + Partnership search = new Partnership(); + search.setName("no-such-partnership"); + assertThrows(PartnershipNotFoundException.class, () -> factory.getPartnership(search, false)); + } + + @Test + public void writeMethodsPersistAndReloadState() throws Exception { + Map partnerAttrs = new HashMap(); + partnerAttrs.put("name", "OpenAS2C"); + partnerAttrs.put("as2_id", "OpenAS2C_ID"); + factory.addPartner(partnerAttrs); + assertNotNull(factory.getPartners().get("OpenAS2C")); + + // Duplicate partner names are rejected + assertThrows(org.openas2.OpenAS2Exception.class, () -> factory.addPartner(partnerAttrs)); + + Map psAttrs = new HashMap(); + psAttrs.put("protocol", "as2"); + factory.addPartnership("OpenAS2C-to-OpenAS2A", "OpenAS2C", "OpenAS2A", psAttrs); + Partnership search = new Partnership(); + search.setName("OpenAS2C-to-OpenAS2A"); + Partnership ps = factory.getPartnership(search, false); + assertEquals("OpenAS2C_ID", ps.getSenderID("as2_id")); + assertEquals("OpenAS2A_ID", ps.getReceiverID("as2_id")); + assertEquals("as2", ps.getAttribute("protocol")); + + // A partner referenced by a partnership cannot be deleted + assertThrows(org.openas2.OpenAS2Exception.class, () -> factory.deletePartner("OpenAS2C")); + + // Partnerships referencing unknown partners are rejected + assertThrows(org.openas2.OpenAS2Exception.class, () -> factory.addPartnership("bad", "no-such-partner", "OpenAS2A", new HashMap())); + + factory.deletePartnership("OpenAS2C-to-OpenAS2A"); + assertThrows(PartnershipNotFoundException.class, () -> factory.getPartnership(search, false)); + factory.deletePartner("OpenAS2C"); + assertNull(factory.getPartners().get("OpenAS2C")); + + // Deleting things that no longer exist is an error + assertThrows(org.openas2.OpenAS2Exception.class, () -> factory.deletePartner("OpenAS2C")); + assertThrows(org.openas2.OpenAS2Exception.class, () -> factory.deletePartnership("OpenAS2C-to-OpenAS2A")); + } + + @Test + public void refreshPicksUpDatabaseChanges() throws Exception { + try (Statement s = seedConn.createStatement()) { + s.executeUpdate(partnershipAttr(2, "attribute", "subject", "Added after startup")); + } + try { + Partnership search = new Partnership(); + search.setName("OpenAS2B-to-OpenAS2A"); + assertNull(factory.getPartnership(search, false).getAttribute("subject")); + + factory.refresh(); + + assertEquals("Added after startup", factory.getPartnership(search, false).getAttribute("subject")); + } finally { + // Leave the table as the other tests expect it + try (Statement s = seedConn.createStatement()) { + s.executeUpdate("DELETE FROM partnership_attribute WHERE PARTNERSHIP_ID = 2 AND ATTRIBUTE_NAME = 'subject'"); + } + factory.refresh(); + } + } +} diff --git a/Server/src/test/resources/config/db_ddl.sql b/Server/src/test/resources/config/db_ddl.sql index c6df5edf5..2572a2426 100644 --- a/Server/src/test/resources/config/db_ddl.sql +++ b/Server/src/test/resources/config/db_ddl.sql @@ -40,3 +40,64 @@ CREATE TABLE msg_metadata ); CREATE UNIQUE INDEX MSG_ID_UNIQUE ON msg_metadata (MSG_ID); + +-- ----------------------------------------------------------------------- +-- partner / partnership tables used by org.openas2.partner.DbPartnershipFactory +-- to load partnerships from a database instead of the partnerships XML file. +-- partnership_attribute.CATEGORY is 'attribute' for regular partnership +-- attributes, 'sender'/'receiver' for per-partnership overrides applied on +-- top of the linked partner's attributes or 'pollerConfig' for the attributes +-- of a partnership specific directory poller. +-- ----------------------------------------------------------------------- + +DROP TABLE partnership_attribute IF EXISTS; +DROP TABLE partnership IF EXISTS; +DROP TABLE partner_attribute IF EXISTS; +DROP TABLE partner IF EXISTS; + +CREATE TABLE partner +( + ID INTEGER NOT NULL AUTO_INCREMENT, + NAME VARCHAR(255) NOT NULL, + PRIMARY KEY (ID) +); + +CREATE UNIQUE INDEX PARTNER_NAME_UNIQUE ON partner (NAME); + +CREATE TABLE partner_attribute +( + ID INTEGER NOT NULL AUTO_INCREMENT, + PARTNER_ID INTEGER NOT NULL, + ATTRIBUTE_NAME VARCHAR(255) NOT NULL, + ATTRIBUTE_VALUE VARCHAR(4000), + PRIMARY KEY (ID), + FOREIGN KEY (PARTNER_ID) REFERENCES partner (ID) +); + +CREATE UNIQUE INDEX PARTNER_ATTRIBUTE_UNIQUE ON partner_attribute (PARTNER_ID, ATTRIBUTE_NAME); + +CREATE TABLE partnership +( + ID INTEGER NOT NULL AUTO_INCREMENT, + NAME VARCHAR(255) NOT NULL, + SENDER_PARTNER_ID INTEGER NOT NULL, + RECEIVER_PARTNER_ID INTEGER NOT NULL, + PRIMARY KEY (ID), + FOREIGN KEY (SENDER_PARTNER_ID) REFERENCES partner (ID), + FOREIGN KEY (RECEIVER_PARTNER_ID) REFERENCES partner (ID) +); + +CREATE UNIQUE INDEX PARTNERSHIP_NAME_UNIQUE ON partnership (NAME); + +CREATE TABLE partnership_attribute +( + ID INTEGER NOT NULL AUTO_INCREMENT, + PARTNERSHIP_ID INTEGER NOT NULL, + CATEGORY VARCHAR(20) DEFAULT 'attribute' NOT NULL, + ATTRIBUTE_NAME VARCHAR(255) NOT NULL, + ATTRIBUTE_VALUE VARCHAR(4000), + PRIMARY KEY (ID), + FOREIGN KEY (PARTNERSHIP_ID) REFERENCES partnership (ID) +); + +CREATE UNIQUE INDEX PARTNERSHIP_ATTRIBUTE_UNIQUE ON partnership_attribute (PARTNERSHIP_ID, CATEGORY, ATTRIBUTE_NAME);