From 4b8503646d1c660212d7fd3496f8d4b32a713981 Mon Sep 17 00:00:00 2001 From: Jake Huneau Date: Tue, 14 Jul 2026 09:41:55 -0400 Subject: [PATCH] Add database-backed partnership factory supporting all OpenAS2 databases Adds org.openas2.partner.DbPartnershipFactory as an alternative to XMLPartnershipFactory, storing partners and partnerships in database tables instead of the partnerships XML file. Works with any JDBC database (embedded H2, Azure SQL, PostgreSQL, MySQL, Oracle) using the same connection parameters as the DbTrackingModule. - New tables partner, partner_attribute, partnership and partnership_attribute added to openas2-schema.xml (ddlutils, for all supported platforms) and db_ddl.sql (H2). Attribute categories: attribute, sender/receiver overrides and pollerConfig. - Partner/partnership add and delete console and REST API commands dispatch on factory type and write to the database, including pollerConfig.* parameters. - Per-partnership directory pollers are launched from pollerConfig category rows, reusing the pollerConfigBase merge and $partnership.*$ placeholder logic shared via BasePartnershipFactory. - In-memory state reloads at startup, after every API change, via the refresh command and on an optional fixed interval. - Tracking module DB handlers made public for reuse by the factory. Verified with unit tests plus a live server run exercising the REST API (add/view/delete partners and partnerships, poller launch, persistence across restart). --- RELEASE-NOTES.md | 1 + Server/src/config/config.xml | 14 + Server/src/config/db_ddl.sql | 61 ++ .../app/partner/AddPartnerCommand.java | 43 +- .../app/partner/AddPartnershipCommand.java | 26 + .../app/partner/DeletePartnerCommand.java | 11 + .../app/partner/DeletePartnershipCommand.java | 11 + .../app/partner/StorePartnershipsCommand.java | 4 + .../partner/BasePartnershipFactory.java | 84 +++ .../openas2/partner/DbPartnershipFactory.java | 541 ++++++++++++++++++ .../partner/XMLPartnershipFactory.java | 71 --- .../msgtracking/EmbeddedDBHandler.java | 2 +- .../msgtracking/ExternalDBHandler.java | 2 +- .../processor/msgtracking/IDBHandler.java | 2 +- Server/src/resources/db/openas2-schema.xml | 69 +++ .../PartnerCommandsWithDbFactoryTest.java | 120 ++++ .../partner/DbPartnershipFactoryTest.java | 230 ++++++++ Server/src/test/resources/config/db_ddl.sql | 61 ++ 18 files changed, 1264 insertions(+), 89 deletions(-) create mode 100644 Server/src/main/java/org/openas2/partner/DbPartnershipFactory.java create mode 100644 Server/src/test/java/org/openas2/app/partner/PartnerCommandsWithDbFactoryTest.java create mode 100644 Server/src/test/java/org/openas2/partner/DbPartnershipFactoryTest.java 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);