From 72f26a56c349d59e16a3110eb54cec035eb4ee54 Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Thu, 6 Aug 2026 10:00:22 -0600 Subject: [PATCH 1/3] feat(ogc): add public data disclaimer page metadata.identification.terms_of_service has to resolve to something, and no NMBGMR disclaimer page exists to link to -- geoinfo.nmt.edu/disclaimer 404s and the Ocotillo site has no legal page -- so swapping the example.com placeholder for another URL would only move the problem. Serving the page from this API keeps the text versioned and reviewable in git and gives a URL that resolves in every environment, rather than blocking on a page in the UI repo that nobody has published yet. Considered inlining the full text in terms_of_service instead, but OpenAPI 3.0 types info.termsOfService as a URI reference, so a multi-paragraph string there yields a spec-nonconforming document. --- api/disclaimer.py | 113 +++++++++++++++++++++++++++++++++++++++ core/disclaimer.py | 47 ++++++++++++++++ core/initializers.py | 2 + tests/test_disclaimer.py | 68 +++++++++++++++++++++++ 4 files changed, 230 insertions(+) create mode 100644 api/disclaimer.py create mode 100644 core/disclaimer.py create mode 100644 tests/test_disclaimer.py diff --git a/api/disclaimer.py b/api/disclaimer.py new file mode 100644 index 000000000..2e94981d1 --- /dev/null +++ b/api/disclaimer.py @@ -0,0 +1,113 @@ +# =============================================================================== +# 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. +# =============================================================================== +"""Public data disclaimer page. + +Both pygeoapi mounts advertise this URL as +`metadata.identification.terms_of_service`, so it is deliberately +unauthenticated -- an OGC client following the advertised link has no +credentials to present. + +HTML is the default because the pygeoapi landing page renders +terms_of_service as a link a human clicks; JSON is offered for catalog +harvesters that want the text as data rather than markup. +""" + +import html +from typing import Annotated + +from fastapi import APIRouter, Query, Request +from fastapi.responses import HTMLResponse, JSONResponse + +from core.disclaimer import ( + DISCLAIMER_CONTACT_EMAIL, + DISCLAIMER_PARAGRAPHS, + DISCLAIMER_TITLE, +) + +router = APIRouter(tags=["disclaimer"]) + +_STYLE = ( + "max-width:44rem;margin:3rem auto;padding:0 1.25rem;" + "font-family:system-ui,-apple-system,'Segoe UI',sans-serif;" + "line-height:1.6;color:#1a1a1a" +) + + +def _wants_json(request: Request, f: str | None) -> bool: + # An explicit ?f= wins over content negotiation, matching pygeoapi's own + # precedence so the two surfaces behave the same way. + if f is not None: + return f.lower() == "json" + accept = request.headers.get("accept", "") + return "application/json" in accept and "text/html" not in accept + + +def _render_html() -> str: + paragraphs = [] + for paragraph in DISCLAIMER_PARAGRAPHS: + escaped = html.escape(paragraph) + escaped = escaped.replace( + DISCLAIMER_CONTACT_EMAIL, + f'' + f"{DISCLAIMER_CONTACT_EMAIL}", + ) + paragraphs.append(f"

{escaped}

") + body = "\n".join(paragraphs) + title = html.escape(DISCLAIMER_TITLE) + return ( + "\n" + '\n' + " \n" + ' \n' + ' \n' + f" {title} | Ocotillo\n" + " \n" + f' \n' + f"

{title}

\n" + f"{body}\n" + " \n" + "\n" + ) + + +@router.get( + "/disclaimer", + response_class=HTMLResponse, + summary="Data disclaimer and terms of service", + responses={ + 200: { + "content": {"text/html": {}, "application/json": {}}, + "description": "The disclaimer as HTML (default) or JSON (?f=json).", + } + }, +) +def get_disclaimer( + request: Request, + f: Annotated[ + str | None, + Query(description="Response format. Use 'json' for the text as data."), + ] = None, +): + if _wants_json(request, f): + return JSONResponse( + { + "title": DISCLAIMER_TITLE, + "paragraphs": list(DISCLAIMER_PARAGRAPHS), + "contact": DISCLAIMER_CONTACT_EMAIL, + } + ) + return HTMLResponse(_render_html()) diff --git a/core/disclaimer.py b/core/disclaimer.py new file mode 100644 index 000000000..3227074e6 --- /dev/null +++ b/core/disclaimer.py @@ -0,0 +1,47 @@ +# =============================================================================== +# 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. +# =============================================================================== +"""Canonical text of the Ocotillo data disclaimer. + +The disclaimer is served at GET /disclaimer (api/disclaimer.py) and is the +target of `metadata.identification.terms_of_service` in both pygeoapi configs. +It lives here as plain constants rather than a template or a static file so +that the HTML and JSON renderings cannot drift apart, and so it ships with the +`core` package without any package-data wiring. +""" + +DISCLAIMER_TITLE = "Disclaimer" + +DISCLAIMER_CONTACT_EMAIL = "ocotillo-nmbg@nmt.edu" + +DISCLAIMER_PARAGRAPHS: tuple[str, ...] = ( + "These geospatial data are shared to help the public understand New " + "Mexico's geologic and water resources. All datasets have limitations, " + "particularly when combining data collected at different times, scales, " + "or for different purposes. Users should review the metadata for each " + "dataset and verify conditions on-site before making legal, regulatory, " + "or other high-consequence decisions. All geospatial datasets are " + "inherently scale-dependent.", + "The New Mexico Bureau of Geology and Mineral Resources (NMBGMR) provides " + "these data 'as-is' without warranties. NMBGMR does not guarantee the " + "accuracy, completeness, and timeliness of these data for any particular " + "purpose. Conditions may have changed since the data were collected. " + "Neither NMBGMR nor any partner agency providing data assumes liability " + "for any errors, omissions, or consequences arising from the use or " + "misuse of these data.", + "References to specific products or companies do not imply endorsement. " + "Proper citation of these data is appreciated. Questions or feedback: " + f"{DISCLAIMER_CONTACT_EMAIL}", +) diff --git a/core/initializers.py b/core/initializers.py index 845d831dc..14a246cb4 100644 --- a/core/initializers.py +++ b/core/initializers.py @@ -215,10 +215,12 @@ def register_api_routes(app): from api.geospatial import router as geospatial_router from api.ngwmn import router as ngwmn_router from api.feedback import router as feedback_router + from api.disclaimer import router as disclaimer_router app.include_router(asset_router) app.include_router(author_router) app.include_router(contact_router) + app.include_router(disclaimer_router) app.include_router(geospatial_router) app.include_router(group_router) app.include_router(lexicon_router) 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 From 93206abee8a68aa4cfeba58669eb48b47e0fadc7 Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Thu, 6 Aug 2026 11:08:34 -0600 Subject: [PATCH 2/3] feat(ogc): replace server metadata placeholders Both configs carried example.com in four fields. The internal mount added under A11 postdates the audit, which names only the public file. The change is narrower than the audit implies in one place and wider in another. provider.url was already https://geoinfo.nmt.edu -- the second example.com value is identification.url, a different field. And in pygeoapi 0.23.5 none of these fields appear on the JSON landing page, only in the OpenAPI document and the HTML landing page, with identification.url reaching JSON as the rel=about href. terms_of_service is derived from PYGEOAPI_SERVER_URL by stripping the mount path rather than read from a new variable. PYGEOAPI_SERVER_URL is already set in app.template.yaml and all three CD workflows; a second base-URL variable would be a fourth place to get a deploy wrong. provider.email is added and contact.role deliberately omitted, both to work around pygeoapi mapping quirks documented in comments at those lines. --- core/pygeoapi-config-internal.yml | 18 ++++++++++++++---- core/pygeoapi-config.yml | 27 +++++++++++++++++++++------ core/pygeoapi.py | 24 ++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 10 deletions(-) diff --git a/core/pygeoapi-config-internal.yml b/core/pygeoapi-config-internal.yml index 7bfbb1590..f2ddb5001 100644 --- a/core/pygeoapi-config-internal.yml +++ b/core/pygeoapi-config-internal.yml @@ -23,18 +23,28 @@ metadata: Authenticated internal OGC API - Features backed by PostGIS and pygeoapi. Unlike the public /ogcapi mount, these collections are not filtered by release_status and include private and draft records. + Provided without warranty - see the terms of service for data + limitations. keywords: [features, ogcapi, postgis, pygeoapi, internal] - terms_of_service: https://example.com/terms - url: https://example.com + terms_of_service: {terms_of_service_url} + url: https://ocotillo.newmexicowaterdata.org license: name: CC-BY 4.0 url: https://creativecommons.org/licenses/by/4.0/ provider: name: NMBGMR url: https://geoinfo.nmt.edu + # pygeoapi builds OpenAPI info.contact from `provider`, not `contact` + # (pygeoapi/openapi.py gen_contact), so info.contact.email is empty + # without this line. + email: ocotillo-nmbg@nmt.edu contact: - name: API Support - email: support@example.com + name: Ocotillo Support, NMBGMR + email: ocotillo-nmbg@nmt.edu + # No `role:` here. pygeoapi 0.23.5 writes contact.role into + # x-ogc-serviceContact.hoursOfService (pygeoapi/openapi.py, gen_contact -- + # the line is a copy-paste of the `hours` branch above it), so setting it + # publishes "pointOfContact" as the service's hours of operation. resources: locations: diff --git a/core/pygeoapi-config.yml b/core/pygeoapi-config.yml index ccae84eab..fb6fc6c2c 100644 --- a/core/pygeoapi-config.yml +++ b/core/pygeoapi-config.yml @@ -19,19 +19,34 @@ logging: metadata: identification: title: Ocotillo OGC API - description: OGC API - Features backed by PostGIS and pygeoapi - keywords: [features, ogcapi, postgis, pygeoapi] - terms_of_service: https://example.com/terms - url: https://example.com + # The disclaimer pointer is repeated here because the JSON landing page + # carries only title/description/links -- terms_of_service below reaches + # the HTML landing page and the OpenAPI document, but not JSON clients. + description: >- + OGC API - Features service publishing New Mexico Bureau of Geology and + Mineral Resources groundwater, geochemistry, and monitoring-location + data. Provided without warranty - see the terms of service for data + limitations. + keywords: [features, ogcapi, postgis, pygeoapi, groundwater, new mexico] + terms_of_service: {terms_of_service_url} + url: https://ocotillo.newmexicowaterdata.org license: name: CC-BY 4.0 url: https://creativecommons.org/licenses/by/4.0/ provider: name: NMBGMR url: https://geoinfo.nmt.edu + # pygeoapi builds OpenAPI info.contact from `provider`, not `contact` + # (pygeoapi/openapi.py gen_contact), so info.contact.email is empty + # without this line. + email: ocotillo-nmbg@nmt.edu contact: - name: API Support - email: support@example.com + name: Ocotillo Support, NMBGMR + email: ocotillo-nmbg@nmt.edu + # No `role:` here. pygeoapi 0.23.5 writes contact.role into + # x-ogc-serviceContact.hoursOfService (pygeoapi/openapi.py, gen_contact -- + # the line is a copy-paste of the `hours` branch above it), so setting it + # publishes "pointOfContact" as the service's hours of operation. resources: locations: diff --git a/core/pygeoapi.py b/core/pygeoapi.py index e5bb3c34a..7a3a35126 100644 --- a/core/pygeoapi.py +++ b/core/pygeoapi.py @@ -5,6 +5,7 @@ import textwrap from importlib.util import find_spec from pathlib import Path +from urllib.parse import urlparse import yaml from fastapi import FastAPI @@ -198,6 +199,28 @@ def _internal_server_url() -> str: return f"http://localhost:8000{_internal_mount_path()}" +def _app_base_url() -> str: + # Derived from PYGEOAPI_SERVER_URL rather than a dedicated env var: that + # variable is already set in app.template.yaml and all three CD workflows, + # and a second base-URL variable would be a fourth place to get a deploy + # wrong. PYGEOAPI_SERVER_URL points at the mount (".../ogcapi"), so strip + # the mount path back off to recover the application root. + server_url = _server_url() + mount_path = _mount_path() + if server_url.endswith(mount_path): + return server_url[: -len(mount_path)].rstrip("/") + # Deployment where the advertised OGC URL is not simply + # (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, From 14df42b2638050f71d6297902ddd45d845407501 Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Thu, 6 Aug 2026 11:22:20 -0600 Subject: [PATCH 3/3] test(ogc): cover service metadata and disclaimer The two @A2 scenarios have existed since the feature file was written but had no step definitions, and carried no tag that CI filters on, so nothing ran them and the placeholders survived unnoticed. The second scenario is retargeted from the landing page to the OpenAPI document. In pygeoapi 0.23.5 the JSON landing page returns only title, description and links, so the provider and contact values it named could never have been asserted there as originally written. terms_of_service is verified by resolving the advertised URL and looking for the disclaimer text, not by matching a literal table value. The value is environment-dependent, and an advertised URL that 404s is no better than a placeholder, which is the failure worth catching. --- tests/features/ogc-cleanup-sprint1.feature | 27 +++-- tests/features/steps/ogc-cleanup-sprint1.py | 106 +++++++++++++++++++- tests/test_ogc.py | 56 +++++++++++ 3 files changed, 173 insertions(+), 16 deletions(-) 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_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(