diff --git a/app/templates/product_detail.html b/app/templates/product_detail.html
index a569d2d..a6abc55 100644
--- a/app/templates/product_detail.html
+++ b/app/templates/product_detail.html
@@ -282,7 +282,8 @@
Combined product view
{% else %}
- This product does not currently have eligible product/vendor synonym or equivalent links.
+ This product does not currently have eligible product or vendor relationships for a combined view
+ ({{ combined_view_relationship_types|join(", ") }}).
{% endif %}
diff --git a/app/templates/vendor_detail.html b/app/templates/vendor_detail.html
index 3ee38f7..2338f7d 100644
--- a/app/templates/vendor_detail.html
+++ b/app/templates/vendor_detail.html
@@ -264,7 +264,8 @@ Combined products view
{% else %}
- This vendor does not currently have eligible synonym/equivalent vendor links for combined products.
+ This vendor does not currently have eligible vendor relationships for combined products
+ ({{ combined_view_relationship_types|join(", ") }}).
{% endif %}
diff --git a/app/views.py b/app/views.py
index 77790ee..a081061 100644
--- a/app/views.py
+++ b/app/views.py
@@ -178,6 +178,7 @@ def _limited_page(query, page, per_page):
"synonym-of",
"canonical-of",
"equivalent-to",
+ "renamed-to",
}
ALLOWED_PROPOSAL_TYPES = {
"edit_cpe",
diff --git a/tests/test_combined_views.py b/tests/test_combined_views.py
new file mode 100644
index 0000000..fc061c8
--- /dev/null
+++ b/tests/test_combined_views.py
@@ -0,0 +1,70 @@
+import pytest
+
+from app.models import CPEEntry, EntityRelationship, Product, Vendor, db
+
+
+@pytest.mark.parametrize("entity_type", ["vendor", "product"])
+def test_rename_chains_are_combined_from_either_end(client, app, entity_type):
+ vendors = [Vendor(name=f"rename_vendor_{index}") for index in range(3)]
+ db.session.add_all(vendors)
+ db.session.flush()
+ products = [
+ Product(
+ vendor=vendor,
+ name="same_token" if entity_type == "vendor" else f"rename_product_{index}",
+ )
+ for index, vendor in enumerate(vendors)
+ ]
+ db.session.add_all(products)
+ db.session.flush()
+ cpes = [
+ CPEEntry(
+ vendor=product.vendor,
+ product=product,
+ cpe_uri=f"cpe:2.3:a:{product.vendor.name}:{product.name}:1:*:*:*:*:*:*:*",
+ part="a",
+ version="1",
+ )
+ for product in products
+ ]
+ records = vendors if entity_type == "vendor" else products
+ for source, target in zip(records, records[1:]):
+ db.session.add(EntityRelationship(
+ **{f"source_{entity_type}": source, f"target_{entity_type}": target},
+ relationship_type="renamed-to",
+ ))
+ # Other products under renamed vendors belong only in the vendor combined view.
+ other_product = Product(vendor=vendors[1], name="unrelated_token")
+ db.session.add_all(cpes + [other_product])
+ db.session.commit()
+
+ for index in (0, 2):
+ if entity_type == "vendor":
+ response = client.get(f"/vendors/{vendors[index].uuid}")
+ assert response.status_code == 200
+ combined = response.get_data(as_text=True).split(
+ "Combined products view
", 1
+ )[1]
+ for product in [*products, other_product]:
+ assert product.name in combined
+ if product.vendor_id != vendors[index].id:
+ assert f'href="/products/{product.uuid}"' in combined
+ assert "renamed-to" in combined
+
+ response = client.get(f"/products/{products[index].uuid}")
+ assert response.status_code == 200
+ combined = response.get_data(as_text=True).split(
+ "Combined product view
", 1
+ )[1].split("PURL mappings
", 1)[0]
+ assert "renamed-to" in combined
+ for cpe in cpes:
+ assert cpe.cpe_uri in combined
+ assert other_product.name not in combined
+
+ graph = client.get(f"/products/{products[index].uuid}/graph")
+ assert graph.status_code == 200
+ combined_product_ids = {
+ node["id"] for node in graph.get_json()["nodes"]
+ if node["data"]["kind"] == "product" and node["data"]["combined"]
+ }
+ assert combined_product_ids == {f"product:{product.uuid}" for product in products}