+ # (a rewriting proxy, say). Scheme + netloc is the best root available.
+ parsed = urlparse(server_url)
+ if parsed.scheme and parsed.netloc:
+ return f"{parsed.scheme}://{parsed.netloc}"
+ return server_url.rstrip("/")
+
+
+def _terms_of_service_url() -> str:
+ return f"{_app_base_url()}/disclaimer"
+
+
def _pygeoapi_dir(
runtime_dir_env: str = "PYGEOAPI_RUNTIME_DIR", default: str = "/tmp/pygeoapi"
) -> Path:
@@ -383,6 +406,7 @@ def _write_config(
)
config = template.format(
server_url=server_url,
+ terms_of_service_url=_terms_of_service_url(),
postgres_host=host,
postgres_port=port,
postgres_db=dbname,
diff --git a/tests/features/ogc-cleanup-sprint1.feature b/tests/features/ogc-cleanup-sprint1.feature
index c8c325dad..6e3c1e71b 100644
--- a/tests/features/ogc-cleanup-sprint1.feature
+++ b/tests/features/ogc-cleanup-sprint1.feature
@@ -99,21 +99,26 @@ Feature: OGC Feature Layer Cleanup — Sprint 1
# A2 — Replace OGC server metadata placeholders in pygeoapi-config.yml
# ---------------------------------------------------------------------------
- @backend @ogc-infrastructure @sprint-1 @high-priority @A2
+ @backend @ogc-infrastructure @sprint-1 @high-priority @A2 @production
Scenario: Service metadata contains no placeholder or example.com values
Given the service configuration has been updated with accurate metadata
- When a client requests the /ogcapi landing page
- Then the response body contains no "example.com" strings
+ When a client requests the /ogcapi landing page as JSON, as HTML, and as OpenAPI
+ Then no response body contains an "example.com" string
- @backend @ogc-infrastructure @sprint-1 @high-priority @A2 @wip
- Scenario: Landing page reflects correct contact and provider information
- When a client requests the /ogcapi landing page
+ @backend @ogc-infrastructure @sprint-1 @high-priority @A2 @production
+ Scenario: Service metadata reflects correct contact and provider information
+ When a client requests the /ogcapi OpenAPI document
Then the service metadata fields match the following values:
- | field | expected-value |
- | terms_of_service | TODO: confirm with technical lead |
- | provider_url | https://geoinfo.nmt.edu |
- | contact_name | TODO: confirm with technical lead |
- | contact_email | ocotillo-nmbg@nmt.edu |
+ | field | expected-value |
+ | provider_url | https://geoinfo.nmt.edu |
+ | contact_name | Ocotillo Support, NMBGMR |
+ | contact_email | ocotillo-nmbg@nmt.edu |
+ And the terms of service URL resolves to the service disclaimer page
+ # The three fields above are asserted against the OpenAPI document, not
+ # the JSON landing page: in pygeoapi 0.23.5 the JSON landing page carries
+ # only title, description, and links. terms_of_service is checked by
+ # resolving it, because an advertised URL that 404s is no better than a
+ # placeholder.
# ---------------------------------------------------------------------------
# A3 — Fix broken README example URLs
diff --git a/tests/features/steps/ogc-cleanup-sprint1.py b/tests/features/steps/ogc-cleanup-sprint1.py
index 2b27e539f..6d701dedd 100644
--- a/tests/features/steps/ogc-cleanup-sprint1.py
+++ b/tests/features/steps/ogc-cleanup-sprint1.py
@@ -13,18 +13,20 @@
# See the License for the specific language governing permissions and
# limitations under the License.
# ===============================================================================
-"""Step definitions for A1 (public release_status filter on ogc_* views) and
-A11 (authenticated internal OGC mount at /ogcapi-internal).
+"""Step definitions for A1 (public release_status filter on ogc_* views),
+A2 (OGC server metadata placeholders) and A11 (authenticated internal OGC
+mount at /ogcapi-internal).
-Only the @A1- and @A11-tagged scenarios in ogc-cleanup-sprint1.feature are
-implemented here. The other ~9 tickets sharing that feature file have no
-steps yet and stay undefined/dormant, per this ticket's plan.
+Only the @A1-, @A2- and @A11-tagged scenarios in ogc-cleanup-sprint1.feature
+are implemented here. The other ~8 tickets sharing that feature file have no
+steps yet and stay undefined/dormant, per those tickets' plans.
"""
import importlib
import os
from datetime import date
from unittest.mock import patch
+from urllib.parse import urlparse
from alembic import command
from behave import given, when, then
@@ -855,4 +857,98 @@ def step_then_no_collection_id_prefixed(context, prefix):
assert not offending, f"found collections with id prefixed {prefix!r}: {offending}"
+# ---------------------------------------------------------------------------
+# A2 -- Replace OGC server metadata placeholders in pygeoapi-config.yml
+# ---------------------------------------------------------------------------
+
+
+@given("the service configuration has been updated with accurate metadata")
+def step_given_service_metadata_updated(context):
+ # No-op marker: core/pygeoapi-config.yml is the artifact under test, so
+ # there is no runtime state to arrange. Mirrors how the A1/A11 givens
+ # treat already-applied state.
+ pass
+
+
+@when("a client requests the /ogcapi landing page as JSON, as HTML, and as OpenAPI")
+def step_when_request_landing_page_all_formats(context):
+ context.metadata_responses = {
+ "landing page (JSON)": context.client.get("/ogcapi", params={"f": "json"}),
+ "landing page (HTML)": context.client.get("/ogcapi", params={"f": "html"}),
+ "OpenAPI document": context.client.get("/ogcapi/openapi"),
+ }
+ for label, response in context.metadata_responses.items():
+ assert (
+ response.status_code == 200
+ ), f"{label} returned {response.status_code}, expected 200"
+
+
+@then('no response body contains an "{needle}" string')
+def step_then_no_response_contains(context, needle):
+ offending = [
+ label
+ for label, response in context.metadata_responses.items()
+ if needle in response.text
+ ]
+ assert not offending, f"{needle!r} still present in: {', '.join(offending)}"
+
+
+@when("a client requests the /ogcapi OpenAPI document")
+def step_when_request_openapi_document(context):
+ context.response = context.client.get("/ogcapi/openapi")
+ assert (
+ context.response.status_code == 200
+ ), f"/ogcapi/openapi returned {context.response.status_code}, expected 200"
+
+
+def _openapi_metadata_field(info, field):
+ # pygeoapi maps metadata.provider onto OpenAPI info.contact and
+ # metadata.contact onto the x-ogc-serviceContact extension
+ # (pygeoapi/openapi.py gen_contact) -- neither is on the JSON landing page.
+ contact = info["contact"]
+ service_contact = contact["x-ogc-serviceContact"]
+ if field == "provider_url":
+ return contact["url"]
+ if field == "contact_name":
+ return service_contact["name"]
+ if field == "contact_email":
+ return service_contact["emails"][0]["value"]
+ raise KeyError(f"unmapped metadata field {field!r}")
+
+
+@then("the service metadata fields match the following values:")
+def step_then_service_metadata_fields_match(context):
+ info = context.response.json()["info"]
+ mismatches = []
+ for row in context.table:
+ field = row["field"]
+ expected = row["expected-value"]
+ actual = _openapi_metadata_field(info, field)
+ if actual != expected:
+ mismatches.append(f"{field}: expected {expected!r}, got {actual!r}")
+ assert not mismatches, "; ".join(mismatches)
+
+
+@then("the terms of service URL resolves to the service disclaimer page")
+def step_then_terms_of_service_resolves(context):
+ terms_url = context.response.json()["info"]["termsOfService"]
+ parsed = urlparse(terms_url)
+ assert parsed.scheme in (
+ "http",
+ "https",
+ ), f"termsOfService {terms_url!r} is not an absolute http(s) URL"
+ assert (
+ parsed.path == "/disclaimer"
+ ), f"termsOfService {terms_url!r} does not point at /disclaimer"
+
+ response = context.client.get(parsed.path)
+ assert response.status_code == 200, (
+ f"advertised termsOfService {terms_url!r} returned "
+ f"{response.status_code} -- a 404 is no better than a placeholder"
+ )
+ assert (
+ "New Mexico Bureau of Geology and Mineral Resources" in response.text
+ ), f"{terms_url!r} resolved but does not look like the disclaimer page"
+
+
# ============= EOF =============================================
diff --git a/tests/test_disclaimer.py b/tests/test_disclaimer.py
new file mode 100644
index 000000000..be694d4e6
--- /dev/null
+++ b/tests/test_disclaimer.py
@@ -0,0 +1,68 @@
+# ===============================================================================
+# Copyright 2026
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+from core.disclaimer import (
+ DISCLAIMER_CONTACT_EMAIL,
+ DISCLAIMER_PARAGRAPHS,
+ DISCLAIMER_TITLE,
+)
+from tests import client
+
+
+def test_disclaimer_html():
+ response = client.get("/disclaimer")
+ assert response.status_code == 200
+ assert response.headers["content-type"].startswith("text/html")
+
+ body = response.text
+ assert f"{DISCLAIMER_TITLE}
" in body
+ assert f'href="mailto:{DISCLAIMER_CONTACT_EMAIL}"' in body
+ assert "New Mexico Bureau of Geology and Mineral Resources" in body
+ assert body.count("") == len(DISCLAIMER_PARAGRAPHS)
+
+
+def test_disclaimer_json():
+ response = client.get("/disclaimer", params={"f": "json"})
+ assert response.status_code == 200
+ assert response.headers["content-type"].startswith("application/json")
+
+ payload = response.json()
+ assert payload["title"] == DISCLAIMER_TITLE
+ assert payload["contact"] == DISCLAIMER_CONTACT_EMAIL
+ assert payload["paragraphs"] == list(DISCLAIMER_PARAGRAPHS)
+
+
+def test_disclaimer_json_via_accept_header():
+ response = client.get("/disclaimer", headers={"Accept": "application/json"})
+ assert response.status_code == 200
+ assert response.headers["content-type"].startswith("application/json")
+
+
+def test_disclaimer_html_wins_when_browser_accepts_both():
+ # Browsers send Accept: text/html,...,*/*, which must not be read as a
+ # request for the JSON representation.
+ response = client.get(
+ "/disclaimer",
+ headers={"Accept": "text/html,application/xhtml+xml,application/json;q=0.9"},
+ )
+ assert response.status_code == 200
+ assert response.headers["content-type"].startswith("text/html")
+
+
+def test_disclaimer_requires_no_authentication():
+ # The pygeoapi configs advertise this URL as terms_of_service, so an OGC
+ # client following the link has no credentials to present.
+ response = client.get("/disclaimer")
+ assert response.status_code == 200
diff --git a/tests/test_ogc.py b/tests/test_ogc.py
index f711b9caa..5385d243d 100644
--- a/tests/test_ogc.py
+++ b/tests/test_ogc.py
@@ -15,6 +15,7 @@
# ===============================================================================
from datetime import date, datetime
from importlib.util import find_spec
+from urllib.parse import urlparse
import pytest
from fastapi.testclient import TestClient
@@ -95,6 +96,61 @@ def test_ogc_openapi_has_paths(ogc_client):
assert "/collections" in payload["paths"]
+# A2: every surface that echoes metadata from core/pygeoapi-config.yml.
+# The JSON landing page carries only title/description/links, so the
+# provider/contact/terms assertions below have to go through the OpenAPI
+# document -- see pygeoapi.api.landing_page vs pygeoapi.openapi.get_oas_30.
+@pytest.mark.parametrize(
+ "path,params",
+ [
+ ("/ogcapi", {"f": "json"}),
+ ("/ogcapi", {"f": "html"}),
+ ("/ogcapi/openapi", {}),
+ ("/ogcapi/collections", {"f": "json"}),
+ ],
+)
+def test_ogc_metadata_has_no_placeholders(ogc_client, path, params):
+ response = ogc_client.get(path, params=params)
+ assert response.status_code == 200
+ assert "example.com" not in response.text
+
+
+def test_ogc_openapi_contact_metadata(ogc_client):
+ response = ogc_client.get("/ogcapi/openapi?f=json")
+ assert response.status_code == 200
+ info = response.json()["info"]
+
+ # info.contact is built from metadata.provider, and metadata.contact is
+ # nested under the x-ogc-serviceContact extension.
+ assert info["contact"]["name"] == "NMBGMR"
+ assert info["contact"]["url"] == "https://geoinfo.nmt.edu"
+ assert info["contact"]["email"] == "ocotillo-nmbg@nmt.edu"
+
+ service_contact = info["contact"]["x-ogc-serviceContact"]
+ assert service_contact["name"] == "Ocotillo Support, NMBGMR"
+ assert service_contact["emails"][0]["value"] == "ocotillo-nmbg@nmt.edu"
+
+
+def test_ogc_terms_of_service_resolves(ogc_client):
+ response = ogc_client.get("/ogcapi/openapi?f=json")
+ terms_url = response.json()["info"]["termsOfService"]
+ parsed = urlparse(terms_url)
+ assert parsed.scheme in ("http", "https")
+ assert parsed.path == "/disclaimer"
+
+ # An advertised terms_of_service that 404s is no better than a placeholder.
+ disclaimer = ogc_client.get(parsed.path)
+ assert disclaimer.status_code == 200
+ assert "New Mexico Bureau of Geology and Mineral Resources" in disclaimer.text
+
+
+def test_ogc_landing_page_advertises_service_url(ogc_client):
+ response = ogc_client.get("/ogcapi", params={"f": "json"})
+ about = [link for link in response.json()["links"] if link["rel"] == "about"]
+ assert about, "landing page has no rel=about link"
+ assert about[0]["href"] == "https://ocotillo.newmexicowaterdata.org"
+
+
def test_latest_tds_observation_date_falls_back_to_collection_date(water_well_thing):
with session_ctx() as session:
csi = NMA_Chemistry_SampleInfo(