From b152944acf6a31f52a402206c0e1111e64a869c3 Mon Sep 17 00:00:00 2001
From: Stuart Rowlands
Date: Sun, 8 Nov 2026 14:02:36 +1000
Subject: [PATCH 01/30] docs: design, plan and rulings ledger for OAuth
connect, Quant Forms and search page setup
---
docs/2026-11-08-drupal-to-secured-design.md | 151 ++++++++++++++++++++
docs/2026-11-08-drupal-to-secured-ledger.md | 18 +++
docs/2026-11-08-drupal-to-secured-plan.md | 69 +++++++++
3 files changed, 238 insertions(+)
create mode 100644 docs/2026-11-08-drupal-to-secured-design.md
create mode 100644 docs/2026-11-08-drupal-to-secured-ledger.md
create mode 100644 docs/2026-11-08-drupal-to-secured-plan.md
diff --git a/docs/2026-11-08-drupal-to-secured-design.md b/docs/2026-11-08-drupal-to-secured-design.md
new file mode 100644
index 00000000..64a0c84b
--- /dev/null
+++ b/docs/2026-11-08-drupal-to-secured-design.md
@@ -0,0 +1,151 @@
+# Drupal site to secured on Quant: design
+
+Date: 2026-11-08. Scope: module `quant` 2.x (Drupal 11) and the matching portal changes.
+Base: `origin/2.x` at e7a1b95 (contains 2.2.0 Typesense search). 1.x is out of scope.
+
+## 1. OAuth connect ("Log in to Quant")
+
+### Flow
+
+1. The editor opens Quant > API and selects the platform (QuantCDN or QuantGov). The
+ platform sets the API endpoint (`api.quantcdn.io` / `api.quantgov.cloud`) and the dashboard
+ (`dashboard.quantcdn.io` / `dash.quantgov.cloud`). A `$settings` override stays possible.
+2. The editor clicks "Log in to Quant". The module sends the browser to
+ `{dashboard}/oauth/authorize` with `client_id=drupal-quant`, `scope=full_access`, `state`,
+ and an S256 PKCE challenge. The state and the verifier are in the Drupal session and are
+ bound to the user id.
+3. The editor approves in the dashboard (2FA, organisation choice: existing consent screen).
+4. The callback (`/admin/config/development/quant/api/oauth/callback`) checks the state and
+ exchanges the code at `{dashboard}/oauth/token`.
+5. The module calls `GET /api/oauth/user` and lists the projects of each granted organisation
+ (`GET /api/v2/organizations/{org}/projects`). The editor picks one project.
+6. The module limits the OAuth token to that project (`POST /api/oauth/token/project`), then
+ reads the project write token (`GET .../projects/{project}?with_token=1`).
+7. The module stores the connection. All existing v1 content calls continue to use the
+ `Quant-Customer` / `Quant-Project` / `Quant-Token` headers. The v1 content API does not
+ accept the OAuth bearer, so this matches the WordPress plugin 2.0.x.
+
+To pick another project, the editor disconnects and logs in again. A limited token cannot
+widen (the portal returns 409), and this is the intended security property.
+
+### Storage
+
+- Tokens are never in exportable config. The OAuth access token, refresh token, expiry, and the
+ project write token from step 6 go into the KeyValue collection `quant_api.oauth`.
+- `quant_api.settings` keeps `auth_method` (`token` | `oauth`), the organisation and project
+ machine names, the platform and the endpoint. Add a config schema for `quant_api.settings`
+ (there is none today).
+- A new service `quant_api.credentials` resolves the credentials in this order: `$settings`
+ override, OAuth store (if `auth_method=oauth`), config `api_token`. `QuantClient`,
+ `quant_search` and `SeedForm` read the token through this service, not from config.
+- A site fingerprint (as in `ai_provider_quant_cloud`) blocks refresh on a copied database.
+
+### Refresh
+
+Port `AuthService` from `ai_provider_quant_cloud`: a lock for one refresh in flight, the 30 s /
+300 s rotation-recovery rules of the portal, `invalid_grant` recorded as "reconnect needed",
+cron refresh at half the lifetime, and a 60 s margin on use. The write token does not expire,
+so a failed refresh never stops publishing. It only stops project listing and token re-sync.
+The settings page shows the connection state and a "Reconnect" button.
+
+### Fallback and limits
+
+- The token form stays as "Connect with a project token" (CI, existing sites, settings.php).
+- Multi-domain sites (`domain_config` overrides of `quant_api.settings`) stay on token
+ connect in this release. The OAuth form shows a notice when overrides exist.
+- Disconnect deletes the local tokens. The portal has no revoke endpoint (open question).
+- Drush: `drush quant:auth-status` prints the state with no token values.
+
+### Portal change (security-sensitive, review before use)
+
+New client `drupal-quant` in `src/config/oauth.php`, modelled on `wordpress-quant` and
+`scriptattest`: public, trusted, `require_pkce`, `allow_project_limit`, scoped tokens with
+`projects:read`, 90d / 365d, `scoped_token_role_checked_at_use`. Redirect URIs: `http(s)://*`
+and `http(s)://*:*` with the path `/admin/config/development/quant/api/oauth/callback`.
+Tests: a client-flip feature test, the consent Authz test (PKCE refused without a challenge),
+and the project-limit test (a limited token can read only its own project write token).
+
+## 2. Quant Forms auto-registration
+
+### How Quant Forms works (facts)
+
+Quant Forms is a per-page switch, not a form object. A published route gets `form_enabled`
+and `form_config`. The CDN takes a POST to that URL, stores the submission, sends the
+notifications and serves the page again with a message inside `id="quant-form-result"`.
+`POST /v1/form` sets the switch, and a content push (`POST /v1`) accepts the same two keys
+inline. A later push without the keys keeps the published values.
+
+### Design
+
+- `quant_webform` adds `form_enabled` and `form_config` inline to the content push of every
+ page that renders a Webform (the Webform canonical page and nodes or blocks that embed one).
+ The inline push removes the "content must exist first" ordering problem.
+- On Webform save, the module queues a re-push of the pages that show that Webform.
+- "Register all" (a form action and `drush quant:webform-register`) queues every such page.
+- Disable or delete of a Webform sends `form_enabled: false` (no delete endpoint exists).
+- The module adds the `quant-form-result` container to each rendered Webform if the form does
+ not already have the Quant result element.
+- The mapper (`WebformQuantConfigMapper`, unit-tested) builds `form_config`:
+ - `mandatory_fields`: required elements that have no conditional `#states`.
+ - `remove_fields`: `form_build_id`, `form_token`, `form_id`, `op`, and the antibot / honeypot
+ timestamp fields.
+ - `honeypot_fields`: the Honeypot module field, if that module protects the form.
+ - `success_message`: the Webform confirmation message (type "message" or "inline").
+ - `notifications.email`: from the first enabled Webform email handler (to, cc, from,
+ subject). Webform tokens are converted to Quant `{{field}}` tokens where a 1:1 mapping
+ exists. Other tokens are removed, and the admin UI lists them.
+ - `notifications.slack`: a per-site webhook from the module settings, kept in KeyValue,
+ never in config.
+ - `spam_threshold` / `spam_detect_action`: module settings (default 0, off).
+ - `recaptcha_secret_key`: from the `recaptcha` module config, if that module protects the form.
+
+### Support matrix
+
+Supported: single-page forms; text, email, textarea, number, tel, url, date, select, radios,
+checkboxes, hidden, likert; composite elements (address, name, contact) as flat keys such as
+`address[city]`; file and managed_file elements (multipart POST, files kept by Quant);
+client-side conditions (`#states`) because the static page keeps the Drupal JavaScript;
+email and Slack notifications; honeypot; reCAPTCHA v2.
+
+Partial: conditionally required fields (enforced in the browser only); email format, min, max
+and pattern checks (browser only); confirmation "inline" (shown as the Quant message).
+
+Not supported (the module shows a warning on the Webform and does not register the form):
+multi-page wizards; AJAX submit; save draft; submission limits; confirmation types "page",
+"url" and "modal"; handlers other than email (remote post, scripts); computed and server-side
+elements; Turnstile and hCaptcha; per-submission tokens in messages. These need a server.
+
+## 3. Quant search page from Drupal
+
+Facts: the portal has no API to create dashboard search pages. `quant_search` already has a
+Drupal search page entity. Drupal renders the page and pushes it to Quant. The page works on
+Algolia and on Typesense (QuantGov).
+
+Design (proposed reading of "creates the Quant search page"):
+- A guided "Create a search page" form in `quant_search`: pick content types; the module
+ proposes facets from the vocabularies attached to those types plus content type and
+ language; default route `search`; enabled; publishes at once; optional "place the
+ autocomplete block". It saves a `quant_search_page` entity and registers the facets.
+- Fix the entity schema (only `id` and `label` are declared), the dead
+ `search_page_repository` service, the `core/jquery.once` dependency, and the docs links.
+- No new portal API, so no new security surface.
+
+Portal UX pass on the dashboard search page wizard (separate branch):
+- One screen with optional sections; default URL `/search`, slash normalised.
+- Facet picker from `attributesForFaceting` instead of free text; filter help per backend.
+- Fix the bug: the wizard saves design options to `search_options`, but the render reads
+ `template_options`, so step 3 has no effect today.
+- Add delete (with `authorize('edit_projects')`) and a link to the live page.
+- Save requires `edit_projects` (the edit route allows `read_projects` today).
+
+## 4. Stretch: Views to search page
+
+Read a View's base table, bundle filter, exposed filters, sorts and pager. Propose a search page
+draft (content types, facets from exposed taxonomy filters, results per page). The editor
+reviews the draft in the form from item 3. Only after items 1 to 3.
+
+## 5. Docs and skills
+
+Update `quant-cms-integrations` (OAuth connect, forms, search page) and the Drupal search
+docs page (Typesense on QuantGov, facet keys `{vocab}_{langcode}`). Also document
+`POST /v1/form` in `content-api.json`. Done last, against the shipped behaviour.
diff --git a/docs/2026-11-08-drupal-to-secured-ledger.md b/docs/2026-11-08-drupal-to-secured-ledger.md
new file mode 100644
index 00000000..9c6f7cc5
--- /dev/null
+++ b/docs/2026-11-08-drupal-to-secured-ledger.md
@@ -0,0 +1,18 @@
+# Drupal site to secured: rulings ledger
+
+Each ruling is reversible unless marked. Format: id, date, ruling, reason.
+
+- R1 (2026-11-08): Base all module work on `origin/2.x` (e7a1b95). The local `2.x` is stale.
+ `fix/composer-project-prefix` is not merged yet and stays out.
+- R2: Keep the v1 content calls on the project write token. The v1 API does not accept the
+ OAuth bearer. Same model as the WordPress plugin 2.0.x.
+- R3: Limit the OAuth token to the chosen project (`allow_project_limit`). Reason: an org-wide
+ `projects:read` token with `with_token=1` can read the write token of every project in the
+ organisation. A leaked Drupal database must expose only one project. (Pending controller review.)
+- R4: Tokens live in KeyValue `quant_api.oauth`, never in exportable config.
+- R5: Multi-domain sites stay on token connect in this release.
+- R6: Forms config goes inline in the content push, not through a separate `POST /v1/form`
+ call, except to disable a form.
+- R7: Multi-page Webforms are not registered; the module warns instead.
+- R8: Item 3 builds on the Drupal-rendered `quant_search_page`; no new portal API.
+ (Pending controller confirmation.)
diff --git a/docs/2026-11-08-drupal-to-secured-plan.md b/docs/2026-11-08-drupal-to-secured-plan.md
new file mode 100644
index 00000000..6c3c9cad
--- /dev/null
+++ b/docs/2026-11-08-drupal-to-secured-plan.md
@@ -0,0 +1,69 @@
+# Drupal site to secured: implementation plan
+
+Design: `docs/2026-11-08-drupal-to-secured-design.md`. Rulings: `docs/2026-11-08-drupal-to-secured-ledger.md`.
+Test site: Drupal 11.4 on SQLite in the session scratchpad (until the shared demo site exists).
+
+## Branches
+
+| Item | Repo | Branch | Base |
+|---|---|---|---|
+| Design, plan, ledger | quant-drupal | `docs/drupal-to-secured` | origin/2.x |
+| 1 OAuth (module) | quant-drupal | `feat/oauth-connect` | origin/2.x |
+| 1 OAuth (portal client) | portal | `sec/drupal-quant-oauth-client` | origin/develop |
+| 2 Forms | quant-drupal | `feat/webform-quant-forms` | feat/oauth-connect (credentials service) |
+| 3 Search (module) | quant-drupal | `feat/search-page-setup` | feat/oauth-connect |
+| 3 Search (portal UX) | portal | `feat/search-page-wizard-ux` | origin/develop |
+| 4 Views converter | quant-drupal | `feat/views-search-converter` | feat/search-page-setup |
+| 5 Docs | quant-agent-skills, quant-docs | `docs/drupal-oauth-forms-search` | their default branches |
+
+Worktrees: `~/apps/.quant-drupal-wt/`, `~/apps/.portal-wt/`.
+
+## Item 1: OAuth connect
+
+1. Portal: add the `drupal-quant` client; tests (client flip, consent Authz, project limit).
+ **Stop. Send the diff summary to the controller for review.**
+2. Module: config schema for `quant_api.settings`; `auth_method`, `platform` keys; update hook.
+3. Module: `quant_api.credentials` service; move every direct `api_token` reader to it
+ (QuantClient, quant_search Search.php / forms / module, SeedForm). Kernel test for the order.
+4. Module: `OAuthTokenStore` (KeyValue), `SiteFingerprint`, `PortalUrl` (per platform).
+5. Module: `AuthService` (authorize URL, PKCE, code exchange, refresh with lock and rotation
+ recovery, refusal state). Unit tests ported from `ai_provider_quant_cloud`.
+6. Module: `OAuthController` (connect, callback, disconnect with CSRF) and the project
+ picker form (list, limit, fetch write token). Kernel and functional tests with a mocked
+ HTTP client.
+7. Module: settings form with two paths ("Log in to Quant", "Connect with a project token"),
+ the multi-domain notice, status display; `hook_cron` refresh; `drush quant:auth-status`.
+8. Manual end to end against the local portal (stage Cloud API) with a local test org only.
+
+## Item 2: Quant Forms
+
+1. `WebformQuantConfigMapper` + support classifier; unit tests for every row of the matrix.
+2. Inline `form_enabled` / `form_config` in the content push (QuantApi `onOutput` event or a
+ new event); kernel test of the payload.
+3. Webform save / disable / delete hooks; queue re-push of the pages that show the form.
+4. "Register all" form action and `drush quant:webform-register`.
+5. Settings: Slack webhook (KeyValue), spam threshold; result container injection.
+6. Admin warnings on unsupported Webforms; functional test.
+
+## Item 3: Search page
+
+1. Fix `quant_search_page` schema, dead service, `core/jquery.once`, docs links; kernel test
+ for config schema validity.
+2. Guided "Create a search page" form with facet proposal; functional test.
+3. Portal: wizard UX pass (one screen, defaults, facet picker, template_options bug, delete,
+ live link, edit permission); Livewire Authz test per CLAUDE.md; next-gen views via the
+ portal skills.
+
+## Item 4 (stretch): Views converter
+
+1. `ViewToSearchPage` analyser (unit tests with fixture views), "Convert to search page" link.
+
+## Item 5: Docs and skills
+
+1. quant-cms-integrations skill + references/drupal.md; leak-check and lint-skills.
+2. quant-docs: Drupal search page, a Drupal forms page, `POST /v1/form` in content-api.json.
+
+## Checks for each commit
+
+`phpcs --standard=Drupal` on changed files; module phpunit (unit + kernel + functional) via
+the scratch site; for the portal, `wt-phpunit.sh --filter X` only.
From f6e58fc6ed50fa86e5178750887a2d768b8ced8e Mon Sep 17 00:00:00 2001
From: Stuart Rowlands
Date: Sun, 8 Nov 2026 14:03:43 +1000
Subject: [PATCH 02/30] docs: record the controller rulings R3-R13 and
follow-up F1
---
docs/2026-11-08-drupal-to-secured-ledger.md | 28 +++++++++++++++++----
1 file changed, 23 insertions(+), 5 deletions(-)
diff --git a/docs/2026-11-08-drupal-to-secured-ledger.md b/docs/2026-11-08-drupal-to-secured-ledger.md
index 9c6f7cc5..5e0dc05b 100644
--- a/docs/2026-11-08-drupal-to-secured-ledger.md
+++ b/docs/2026-11-08-drupal-to-secured-ledger.md
@@ -8,11 +8,29 @@ Each ruling is reversible unless marked. Format: id, date, ruling, reason.
OAuth bearer. Same model as the WordPress plugin 2.0.x.
- R3: Limit the OAuth token to the chosen project (`allow_project_limit`). Reason: an org-wide
`projects:read` token with `with_token=1` can read the write token of every project in the
- organisation. A leaked Drupal database must expose only one project. (Pending controller review.)
+ organisation. A leaked Drupal database must expose only one project. Approved by the controller
+ 2026-11-08. A "Change project" button restarts the login flow.
- R4: Tokens live in KeyValue `quant_api.oauth`, never in exportable config.
-- R5: Multi-domain sites stay on token connect in this release.
+- R5: Multi-domain sites stay on token connect in this release. Approved. The UI says so next to
+ the login button when multi-domain overrides exist.
- R6: Forms config goes inline in the content push, not through a separate `POST /v1/form`
call, except to disable a form.
-- R7: Multi-page Webforms are not registered; the module warns instead.
-- R8: Item 3 builds on the Drupal-rendered `quant_search_page`; no new portal API.
- (Pending controller confirmation.)
+- R7 (superseded by R9): Multi-page Webforms are not registered; the module warns instead.
+- R8: Item 3 builds on the Drupal-rendered `quant_search_page`; no new portal API. Approved.
+ The portal wizard UX pass stays. The `search_options` / `template_options` bug is its own
+ fix commit with a test.
+- R9 (controller, 2026-11-08): Investigate client-side wizard pages in the static output (all
+ pages in one form, JavaScript steps, client validation per page, one final submit). Support
+ it if it fits the item; otherwise report why, with a size estimate, before dropping it.
+- R10 (controller): AJAX submit, drafts, limits, non-email handlers, page and modal
+ confirmations, Turnstile and hCaptcha stay unsupported with a clear warning. Add the
+ "redirect to URL" confirmation if it is cheap.
+- R11 (controller): Disconnect deletes the local tokens only in this release.
+- R12 (controller): Document `POST /v1/form` in item 5.
+- R13 (controller): Use the scratchpad d11 site until the demo site at ~/apps/quant-demo-drupal
+ reports ready.
+
+## Follow-ups
+
+- F1: Portal OAuth revoke endpoint (RFC 7009), so that disconnect also revokes the tokens.
+ For the controller to schedule.
From bc04c7f785f13ea2e14ff28f7e01f988ad5bc789 Mon Sep 17 00:00:00 2001
From: Stuart Rowlands
Date: Sun, 8 Nov 2026 14:06:06 +1000
Subject: [PATCH 03/30] docs: correct the redirect patterns of the drupal-quant
client
---
docs/2026-11-08-drupal-to-secured-design.md | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/docs/2026-11-08-drupal-to-secured-design.md b/docs/2026-11-08-drupal-to-secured-design.md
index 64a0c84b..496c3c91 100644
--- a/docs/2026-11-08-drupal-to-secured-design.md
+++ b/docs/2026-11-08-drupal-to-secured-design.md
@@ -60,8 +60,9 @@ The settings page shows the connection state and a "Reconnect" button.
New client `drupal-quant` in `src/config/oauth.php`, modelled on `wordpress-quant` and
`scriptattest`: public, trusted, `require_pkce`, `allow_project_limit`, scoped tokens with
-`projects:read`, 90d / 365d, `scoped_token_role_checked_at_use`. Redirect URIs: `http(s)://*`
-and `http(s)://*:*` with the path `/admin/config/development/quant/api/oauth/callback`.
+`projects:read`, 90d / 365d, `scoped_token_role_checked_at_use`. Redirect URIs:
+`http(s)://*/admin/config/development/quant/api/oauth/callback` (the `*/` wildcard allows a
+port). A Drupal site in a subdirectory does not match and uses token connect.
Tests: a client-flip feature test, the consent Authz test (PKCE refused without a challenge),
and the project-limit test (a limited token can read only its own project write token).
From 1647c173611ad7daef5d7f3c245608bc0b4de873 Mon Sep 17 00:00:00 2001
From: Stuart Rowlands
Date: Sun, 8 Nov 2026 14:06:12 +1000
Subject: [PATCH 04/30] docs: ledger R14 on the drupal-quant redirect patterns
---
docs/2026-11-08-drupal-to-secured-ledger.md | 2 ++
1 file changed, 2 insertions(+)
diff --git a/docs/2026-11-08-drupal-to-secured-ledger.md b/docs/2026-11-08-drupal-to-secured-ledger.md
index 5e0dc05b..720746f1 100644
--- a/docs/2026-11-08-drupal-to-secured-ledger.md
+++ b/docs/2026-11-08-drupal-to-secured-ledger.md
@@ -29,6 +29,8 @@ Each ruling is reversible unless marked. Format: id, date, ruling, reason.
- R12 (controller): Document `POST /v1/form` in item 5.
- R13 (controller): Use the scratchpad d11 site until the demo site at ~/apps/quant-demo-drupal
reports ready.
+- R14: The `drupal-quant` redirect patterns cover the module callback at the web root on any
+ host and port. A Drupal site in a subdirectory uses token connect. Same rule as `wordpress-quant`.
## Follow-ups
From fa99dd6aa80d99a510cd735599713815774c2210 Mon Sep 17 00:00:00 2001
From: Stuart Rowlands
Date: Sun, 8 Nov 2026 14:27:40 +1000
Subject: [PATCH 05/30] feat: read the Quant write token through a credentials
service
Adds quant_api.credentials (settings.php override, then the OAuth connection,
then the typed token), the platform and auth_method settings with schema, and
an update hook that sets the platform from an existing endpoint. The client,
quant_search and the seed form no longer read api_token from config.
---
.../config/install/quant_api.settings.yml | 2 +
.../config/schema/quant_api.schema.yml | 6 ++
modules/quant_api/quant_api.install | 32 ++++++++
modules/quant_api/quant_api.services.yml | 37 +++++++++
modules/quant_api/src/Client/QuantClient.php | 14 +++-
modules/quant_api/src/Credentials.php | 77 +++++++++++++++++++
.../quant_search/src/Controller/Search.php | 4 +-
src/Form/SeedForm.php | 2 +-
8 files changed, 169 insertions(+), 5 deletions(-)
create mode 100644 modules/quant_api/quant_api.install
create mode 100644 modules/quant_api/src/Credentials.php
diff --git a/modules/quant_api/config/install/quant_api.settings.yml b/modules/quant_api/config/install/quant_api.settings.yml
index 618cef03..1b1f14fa 100644
--- a/modules/quant_api/config/install/quant_api.settings.yml
+++ b/modules/quant_api/config/install/quant_api.settings.yml
@@ -1 +1,3 @@
api_endpoint: https://api.quantcdn.io
+platform: quantcdn
+auth_method: token
diff --git a/modules/quant_api/config/schema/quant_api.schema.yml b/modules/quant_api/config/schema/quant_api.schema.yml
index 80b8c5e3..79b922cd 100644
--- a/modules/quant_api/config/schema/quant_api.schema.yml
+++ b/modules/quant_api/config/schema/quant_api.schema.yml
@@ -21,3 +21,9 @@ quant_api.settings:
api_tls_disabled:
type: boolean
label: 'Disable TLS verification'
+ platform:
+ type: string
+ label: 'Platform (quantcdn or quantgov)'
+ auth_method:
+ type: string
+ label: 'Connection method (token or oauth)'
diff --git a/modules/quant_api/quant_api.install b/modules/quant_api/quant_api.install
new file mode 100644
index 00000000..f2051c5f
--- /dev/null
+++ b/modules/quant_api/quant_api.install
@@ -0,0 +1,32 @@
+clear();
+ $store->deleteAllPendingLogins();
+}
+
+/**
+ * Add the platform and the connection method to the Quant API settings.
+ */
+function quant_api_update_11001() {
+ $config = \Drupal::configFactory()->getEditable('quant_api.settings');
+ if (!Platform::isKnown($config->get('platform'))) {
+ $config->set('platform', Platform::fromEndpoint($config->get('api_endpoint')));
+ }
+ if (!in_array($config->get('auth_method'), ['token', 'oauth'], TRUE)) {
+ $config->set('auth_method', 'token');
+ }
+ $config->save();
+}
diff --git a/modules/quant_api/quant_api.services.yml b/modules/quant_api/quant_api.services.yml
index ad13d43d..23bd3cf0 100644
--- a/modules/quant_api/quant_api.services.yml
+++ b/modules/quant_api/quant_api.services.yml
@@ -5,6 +5,43 @@ services:
- '@http_client'
- '@config.factory'
- '@logger.factory'
+ - '@quant_api.credentials'
+
+ quant_api.credentials:
+ class: Drupal\quant_api\Credentials
+ arguments:
+ - '@config.factory'
+ - '@quant_api.oauth_store'
+ - '@config.storage'
+
+ quant_api.oauth_store:
+ class: Drupal\quant_api\OAuth\OAuthTokenStore
+ arguments:
+ - '@keyvalue'
+ - '@keyvalue.expirable'
+
+ quant_api.site_fingerprint:
+ class: Drupal\quant_api\OAuth\SiteFingerprint
+ arguments:
+ - '@config.factory'
+
+ quant_api.oauth:
+ class: Drupal\quant_api\OAuth\AuthService
+ arguments:
+ - '@http_client'
+ - '@config.factory'
+ - '@quant_api.oauth_store'
+ - '@quant_api.site_fingerprint'
+ - '@lock'
+ - '@database'
+ - '@logger.factory'
+
+ quant_api.portal:
+ class: Drupal\quant_api\OAuth\PortalClient
+ arguments:
+ - '@http_client'
+ - '@quant_api.oauth'
+ - '@logger.factory'
quant.output.api:
class: Drupal\quant_api\EventSubscriber\QuantApi
diff --git a/modules/quant_api/src/Client/QuantClient.php b/modules/quant_api/src/Client/QuantClient.php
index 424023f9..97311b04 100644
--- a/modules/quant_api/src/Client/QuantClient.php
+++ b/modules/quant_api/src/Client/QuantClient.php
@@ -5,6 +5,7 @@
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Logger\LoggerChannelFactoryInterface;
use Drupal\quant\PublishGuard;
+use Drupal\quant_api\Credentials;
use Drupal\quant_api\Exception\InvalidPayload;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\RequestException;
@@ -81,13 +82,21 @@ class QuantClient implements QuantClientInterface {
*/
protected $configFactory;
+ /**
+ * Resolves the write token (settings.php, OAuth connection or config).
+ *
+ * @var \Drupal\quant_api\Credentials|null
+ */
+ protected $credentials;
+
/**
* {@inheritdoc}
*/
- public function __construct(Client $client, ConfigFactoryInterface $config_factory, LoggerChannelFactoryInterface $logger_factory) {
+ public function __construct(Client $client, ConfigFactoryInterface $config_factory, LoggerChannelFactoryInterface $logger_factory, ?Credentials $credentials = NULL) {
$this->configFactory = $config_factory;
$this->client = $client;
$this->logger = $logger_factory->get('quant_api');
+ $this->credentials = $credentials;
$this->refreshCredentials();
}
@@ -108,7 +117,8 @@ protected function refreshCredentials() : void {
$this->config = $this->configFactory->get('quant_api.settings');
$this->username = $this->config->get('api_account');
- $this->token = $this->config->get('api_token');
+ // An OAuth connection keeps its token outside config.
+ $this->token = $this->credentials ? $this->credentials->getToken($this->config) : $this->config->get('api_token');
$this->project = $this->config->get('api_project');
$this->endpoint = $this->config->get('api_endpoint') . '/v1';
$this->tlsDisabled = $this->config->get('api_tls_disabled');
diff --git a/modules/quant_api/src/Credentials.php b/modules/quant_api/src/Credentials.php
new file mode 100644
index 00000000..7dfca264
--- /dev/null
+++ b/modules/quant_api/src/Credentials.php
@@ -0,0 +1,77 @@
+configStorage->listAll('domain.config.') as $name) {
+ if (str_ends_with($name, '.quant_api.settings')) {
+ return TRUE;
+ }
+ }
+ return FALSE;
+ }
+
+ /**
+ * The write token for the given (possibly domain-overridden) settings.
+ */
+ public function getToken(?Config $config = NULL): ?string {
+ $config = $config ?? $this->configFactory->get('quant_api.settings');
+ if ($config->hasOverrides('api_token')) {
+ return ((string) $config->get('api_token')) ?: NULL;
+ }
+ if ($this->usesOauth($config)) {
+ return ((string) $this->tokenStore->get(OAuthTokenStore::WRITE_TOKEN)) ?: NULL;
+ }
+ return ((string) $config->get('api_token')) ?: NULL;
+ }
+
+ /**
+ * Whether a token is available, without returning it.
+ */
+ public function hasToken(?Config $config = NULL): bool {
+ return $this->getToken($config) !== NULL;
+ }
+
+ /**
+ * Whether the site connects with "Log in to Quant".
+ */
+ public function usesOauth(?Config $config = NULL): bool {
+ $config = $config ?? $this->configFactory->get('quant_api.settings');
+ return $config->get('auth_method') === self::METHOD_OAUTH;
+ }
+
+}
diff --git a/modules/quant_search/src/Controller/Search.php b/modules/quant_search/src/Controller/Search.php
index 27852623..bb77b1ac 100644
--- a/modules/quant_search/src/Controller/Search.php
+++ b/modules/quant_search/src/Controller/Search.php
@@ -61,7 +61,7 @@ public function statusPage() {
$searchEnabled = $this->enabled();
if (!$searchEnabled) {
- if ($config->get('api_token') && $project = $this->client->project()) {
+ if (\Drupal::service('quant_api.credentials')->hasToken($config) && $project = $this->client->project()) {
\Drupal::messenger()->addError(t('Search is not enabled for this project. It can be enabled via the Quant Dashboard. See Quant Search documentation.'));
}
else {
@@ -390,7 +390,7 @@ public function administerAccess(AccountInterface $account) {
public function enabled() {
$config = $this->config(self::SETTINGS);
- $searchEnabled = ($config->get('api_token') && $this->client->project() && $this->client->project()->config->search_enabled);
+ $searchEnabled = (\Drupal::service('quant_api.credentials')->hasToken($config) && $this->client->project() && $this->client->project()->config->search_enabled);
if ($searchEnabled) {
\Drupal::messenger()->addMessage(t('Search is enabled for project %api.', ['%api' => $config->get('api_project')]));
}
diff --git a/src/Form/SeedForm.php b/src/Form/SeedForm.php
index f8f63b7b..4681b293 100644
--- a/src/Form/SeedForm.php
+++ b/src/Form/SeedForm.php
@@ -340,7 +340,7 @@ public function submitForm(array &$form, FormStateInterface $form_state) {
return;
}
- if ($config->get('api_token')) {
+ if (\Drupal::service('quant_api.credentials')->hasToken()) {
if (!$project = $this->client->ping()) {
\Drupal::messenger()->addError(t('Unable to connect to Quant API, check settings.'));
return;
From c5fdcb7d6d0973681fbe9473a84c576f364403ac Mon Sep 17 00:00:00 2001
From: Stuart Rowlands
Date: Sun, 8 Nov 2026 14:27:40 +1000
Subject: [PATCH 06/30] feat: Log in to Quant with OAuth and choose the project
The editor approves the site in the Quant dashboard (client drupal-quant, S256
PKCE, state bound to the session and user) and picks a project. The module
limits the token to that project, reads the project's write token and keeps
both outside config. Change project starts a new login; the old project stays
until the new choice. Cron refreshes the token with the portal's rotation
rules; a database copy never refreshes the original site's tokens. Multi-domain
sites stay on token connect. Adds drush quant:auth-status, kernel tests for the
refresh rules and a functional test against a fake dashboard.
---
modules/quant_api/drush.services.yml | 10 +
modules/quant_api/quant_api.module | 17 +
modules/quant_api/quant_api.routing.yml | 46 ++
.../src/Commands/QuantApiAuthCommands.php | 53 ++
.../src/Controller/OAuthController.php | 166 ++++++
.../quant_api/src/Form/ProjectSelectForm.php | 170 ++++++
modules/quant_api/src/Form/SettingsForm.php | 378 +++++++++---
modules/quant_api/src/OAuth/AuthService.php | 540 ++++++++++++++++++
.../quant_api/src/OAuth/OAuthTokenStore.php | 191 +++++++
modules/quant_api/src/OAuth/Platform.php | 88 +++
modules/quant_api/src/OAuth/PortalClient.php | 146 +++++
.../quant_api/src/OAuth/SiteFingerprint.php | 104 ++++
.../quant_api_oauth_test.info.yml | 8 +
.../quant_api_oauth_test.routing.yml | 8 +
.../quant_api_oauth_test.services.yml | 6 +
.../Controller/FakeDashboardController.php | 29 +
.../src/FakeQuantMiddleware.php | 159 ++++++
.../tests/src/Functional/OAuthConnectTest.php | 196 +++++++
.../tests/src/Kernel/OAuthAuthServiceTest.php | 329 +++++++++++
19 files changed, 2569 insertions(+), 75 deletions(-)
create mode 100644 modules/quant_api/drush.services.yml
create mode 100644 modules/quant_api/quant_api.module
create mode 100644 modules/quant_api/src/Commands/QuantApiAuthCommands.php
create mode 100644 modules/quant_api/src/Controller/OAuthController.php
create mode 100644 modules/quant_api/src/Form/ProjectSelectForm.php
create mode 100644 modules/quant_api/src/OAuth/AuthService.php
create mode 100644 modules/quant_api/src/OAuth/OAuthTokenStore.php
create mode 100644 modules/quant_api/src/OAuth/Platform.php
create mode 100644 modules/quant_api/src/OAuth/PortalClient.php
create mode 100644 modules/quant_api/src/OAuth/SiteFingerprint.php
create mode 100644 modules/quant_api/tests/modules/quant_api_oauth_test/quant_api_oauth_test.info.yml
create mode 100644 modules/quant_api/tests/modules/quant_api_oauth_test/quant_api_oauth_test.routing.yml
create mode 100644 modules/quant_api/tests/modules/quant_api_oauth_test/quant_api_oauth_test.services.yml
create mode 100644 modules/quant_api/tests/modules/quant_api_oauth_test/src/Controller/FakeDashboardController.php
create mode 100644 modules/quant_api/tests/modules/quant_api_oauth_test/src/FakeQuantMiddleware.php
create mode 100644 modules/quant_api/tests/src/Functional/OAuthConnectTest.php
create mode 100644 modules/quant_api/tests/src/Kernel/OAuthAuthServiceTest.php
diff --git a/modules/quant_api/drush.services.yml b/modules/quant_api/drush.services.yml
new file mode 100644
index 00000000..f37d4a95
--- /dev/null
+++ b/modules/quant_api/drush.services.yml
@@ -0,0 +1,10 @@
+services:
+ quant_api.commands:
+ class: \Drupal\quant_api\Commands\QuantApiAuthCommands
+ arguments:
+ - '@config.factory'
+ - '@quant_api.credentials'
+ - '@quant_api.oauth'
+ - '@quant_api.oauth_store'
+ tags:
+ - { name: drush.command }
diff --git a/modules/quant_api/quant_api.module b/modules/quant_api/quant_api.module
new file mode 100644
index 00000000..51c8e9dd
--- /dev/null
+++ b/modules/quant_api/quant_api.module
@@ -0,0 +1,17 @@
+usesOauth()) {
+ \Drupal::service('quant_api.oauth')->refreshFromCron();
+ }
+}
diff --git a/modules/quant_api/quant_api.routing.yml b/modules/quant_api/quant_api.routing.yml
index 94dcb8bd..fbb071f2 100644
--- a/modules/quant_api/quant_api.routing.yml
+++ b/modules/quant_api/quant_api.routing.yml
@@ -7,3 +7,49 @@ quant_api.settings_form:
_permission: 'configure quant'
options:
_admin_route: TRUE
+
+quant_api.oauth_connect:
+ path: '/admin/config/development/quant/api/oauth/connect'
+ defaults:
+ _controller: '\Drupal\quant_api\Controller\OAuthController::connect'
+ _title: 'Log in to Quant'
+ requirements:
+ _permission: 'configure quant'
+ _csrf_token: 'TRUE'
+ options:
+ _admin_route: TRUE
+ no_cache: TRUE
+
+quant_api.oauth_callback:
+ path: '/admin/config/development/quant/api/oauth/callback'
+ defaults:
+ _controller: '\Drupal\quant_api\Controller\OAuthController::callback'
+ _title: 'Log in to Quant'
+ requirements:
+ _permission: 'configure quant'
+ options:
+ _admin_route: TRUE
+ no_cache: TRUE
+
+quant_api.oauth_project:
+ path: '/admin/config/development/quant/api/oauth/project'
+ defaults:
+ _form: '\Drupal\quant_api\Form\ProjectSelectForm'
+ _title: 'Choose a Quant project'
+ requirements:
+ _permission: 'configure quant'
+ options:
+ _admin_route: TRUE
+ no_cache: TRUE
+
+quant_api.oauth_disconnect:
+ path: '/admin/config/development/quant/api/oauth/disconnect'
+ defaults:
+ _controller: '\Drupal\quant_api\Controller\OAuthController::disconnect'
+ _title: 'Log out of Quant'
+ requirements:
+ _permission: 'configure quant'
+ _csrf_token: 'TRUE'
+ options:
+ _admin_route: TRUE
+ no_cache: TRUE
diff --git a/modules/quant_api/src/Commands/QuantApiAuthCommands.php b/modules/quant_api/src/Commands/QuantApiAuthCommands.php
new file mode 100644
index 00000000..8b1f3790
--- /dev/null
+++ b/modules/quant_api/src/Commands/QuantApiAuthCommands.php
@@ -0,0 +1,53 @@
+configFactory->get('quant_api.settings');
+ $oauth = $this->credentials->usesOauth($config) && !$config->hasOverrides('api_token');
+ $rows = [
+ 'Platform' => (string) $config->get('platform'),
+ 'API endpoint' => (string) $config->get('api_endpoint'),
+ 'Organization' => (string) $config->get('api_account'),
+ 'Project' => (string) $config->get('api_project'),
+ 'Method' => $oauth ? 'Log in to Quant' : ($config->hasOverrides('api_token') ? 'Project token (settings.php)' : 'Project token'),
+ 'Token available' => $this->credentials->hasToken($config) ? 'yes' : 'no',
+ ];
+ if ($oauth) {
+ $expires = (int) $this->tokenStore->get(OAuthTokenStore::EXPIRES_AT);
+ $rows['Login expires'] = $expires ? date('Y-m-d H:i', $expires) : '-';
+ $rows['Needs a new login'] = $this->auth->getReconnectReason() ?? 'no';
+ }
+ foreach ($rows as $label => $value) {
+ $this->io()->writeln(sprintf('%-18s %s', $label . ':', $value));
+ }
+ }
+
+}
diff --git a/modules/quant_api/src/Controller/OAuthController.php b/modules/quant_api/src/Controller/OAuthController.php
new file mode 100644
index 00000000..794ce30d
--- /dev/null
+++ b/modules/quant_api/src/Controller/OAuthController.php
@@ -0,0 +1,166 @@
+configFactory = $config_factory;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container) {
+ return new static(
+ $container->get('quant_api.oauth'),
+ $container->get('quant_api.portal'),
+ $container->get('quant_api.oauth_store'),
+ $container->get('quant_api.credentials'),
+ $container->get('config.factory'),
+ );
+ }
+
+ /**
+ * Sends the editor to the dashboard to approve this site.
+ */
+ public function connect(Request $request) {
+ if ($this->credentials->hasDomainOverrides()) {
+ $this->messenger()->addError($this->t('This site publishes several domains to several projects. Log in to Quant supports one project per site, so connect each domain with a project token.'));
+ return $this->redirectToSettings();
+ }
+ $state = bin2hex(random_bytes(16));
+ $verifier = $this->auth->generatePkceVerifier();
+ $redirect_uri = self::callbackUrl();
+ $request->getSession()->set(self::SESSION_KEY, [
+ 'state' => $state,
+ 'verifier' => $verifier,
+ 'uid' => (int) $this->currentUser()->id(),
+ 'created' => time(),
+ ]);
+ $url = $this->auth->getAuthorizationUrl($state, $redirect_uri, $this->auth->computePkceChallenge($verifier));
+ $response = new TrustedRedirectResponse($url);
+ $response->getCacheableMetadata()->setCacheMaxAge(0);
+ return $response;
+ }
+
+ /**
+ * Takes the dashboard's answer and asks for the project.
+ */
+ public function callback(Request $request) {
+ $session = $request->getSession();
+ $login = $session->get(self::SESSION_KEY);
+ // One use only, whatever the outcome.
+ $session->remove(self::SESSION_KEY);
+
+ $error = $this->validateCallback($request, $login);
+ if ($error !== NULL) {
+ $this->messenger()->addError($error);
+ return $this->redirectToSettings();
+ }
+
+ $tokens = $this->auth->exchangeCode((string) $request->query->get('code'), self::callbackUrl(), $login['verifier']);
+ if ($tokens === NULL) {
+ $this->messenger()->addError($this->t('Quant did not accept the login. Try again. If it fails again, connect with a project token.'));
+ return $this->redirectToSettings();
+ }
+
+ $user = is_array($tokens['user'] ?? NULL) ? $tokens['user'] : $this->portal->getUser($tokens['access_token']);
+ unset($tokens['user']);
+ $this->tokenStore->savePendingLogin((int) $this->currentUser()->id(), [
+ 'tokens' => $tokens,
+ 'user' => ['name' => (string) ($user['name'] ?? ''), 'email' => (string) ($user['email'] ?? '')],
+ 'organizations' => self::organizations($user['organizations'] ?? []),
+ ]);
+ return new RedirectResponse(Url::fromRoute('quant_api.oauth_project')->toString());
+ }
+
+ /**
+ * Deletes the local connection and goes back to token connect.
+ */
+ public function disconnect() {
+ $this->auth->disconnect();
+ $this->configFactory->getEditable('quant_api.settings')
+ ->set('auth_method', Credentials::METHOD_TOKEN)
+ ->save();
+ $this->messenger()->addStatus($this->t('This site is logged out of Quant. Content is not published until you log in again or connect with a project token.'));
+ return $this->redirectToSettings();
+ }
+
+ /**
+ * The error message of a bad callback, or NULL when it is valid.
+ */
+ protected function validateCallback(Request $request, mixed $login): ?string {
+ if ($request->query->has('error')) {
+ return (string) $this->t('The login was cancelled in the Quant dashboard. Nothing changed.');
+ }
+ if (!is_array($login) || empty($login['state']) || !is_string($request->query->get('state'))) {
+ return (string) $this->t('The login expired or was started in another browser. Start it again.');
+ }
+ $valid = hash_equals($login['state'], $request->query->get('state'))
+ && (int) $login['uid'] === (int) $this->currentUser()->id()
+ && (int) $login['created'] >= time() - self::STATE_TTL
+ && is_string($request->query->get('code')) && $request->query->get('code') !== '';
+ return $valid ? NULL : (string) $this->t('The login expired or was started in another browser. Start it again.');
+ }
+
+ /**
+ * The granted organisations, machine name => name, safe names only.
+ */
+ public static function organizations(mixed $organizations): array {
+ $result = [];
+ foreach (is_array($organizations) ? $organizations : [] as $org) {
+ $machine = is_array($org) ? (string) ($org['machine_name'] ?? '') : '';
+ if (PortalClient::isMachineName($machine)) {
+ $result[$machine] = (string) ($org['name'] ?? $machine);
+ }
+ }
+ return $result;
+ }
+
+ /**
+ * The absolute callback URL that the dashboard redirects to.
+ */
+ public static function callbackUrl(): string {
+ return Url::fromRoute('quant_api.oauth_callback', [], ['absolute' => TRUE])->toString(TRUE)->getGeneratedUrl();
+ }
+
+ /**
+ * A redirect to the Quant API settings page.
+ */
+ protected function redirectToSettings(): RedirectResponse {
+ return new RedirectResponse(Url::fromRoute('quant_api.settings_form')->toString());
+ }
+
+}
diff --git a/modules/quant_api/src/Form/ProjectSelectForm.php b/modules/quant_api/src/Form/ProjectSelectForm.php
new file mode 100644
index 00000000..f93b9d96
--- /dev/null
+++ b/modules/quant_api/src/Form/ProjectSelectForm.php
@@ -0,0 +1,170 @@
+get('quant_api.oauth'),
+ $container->get('quant_api.portal'),
+ $container->get('quant_api.oauth_store'),
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getFormId() {
+ return 'quant_api_oauth_project';
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function buildForm(array $form, FormStateInterface $form_state) {
+ $login = $this->tokenStore->getPendingLogin((int) $this->currentUser()->id());
+ if ($login === NULL) {
+ return $this->expired($form);
+ }
+ $options = $this->projectOptions($login);
+ if ($options === []) {
+ $form['empty'] = [
+ '#markup' => '' . $this->t('Your Quant account has no projects in the organisations you approved. Create a project in the Quant dashboard, then log in again.') . '
',
+ ];
+ return $form + $this->backLink();
+ }
+
+ $form['intro'] = [
+ '#markup' => '' . $this->t('Logged in as %user. Choose the Quant project that this site publishes to. This site can then reach only that project.', [
+ '%user' => $login['user']['email'] ?: $login['user']['name'],
+ ]) . '
',
+ ];
+ $choices = array_merge(...array_values(array_map('array_keys', $options)));
+ $form['project'] = [
+ '#type' => 'select',
+ '#title' => $this->t('Project'),
+ '#options' => $options,
+ '#required' => TRUE,
+ '#default_value' => count($choices) === 1 ? $choices[0] : NULL,
+ ];
+ $form['actions'] = ['#type' => 'actions'];
+ $form['actions']['submit'] = [
+ '#type' => 'submit',
+ '#value' => $this->t('Use this project'),
+ '#button_type' => 'primary',
+ ];
+ return $form + $this->backLink();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function validateForm(array &$form, FormStateInterface $form_state) {
+ $login = $this->tokenStore->getPendingLogin((int) $this->currentUser()->id());
+ [$org, $project] = array_pad(explode(':', (string) $form_state->getValue('project'), 2), 2, '');
+ if ($login === NULL || !isset($login['organizations'][$org]) || !PortalClient::isMachineName($project)) {
+ $form_state->setErrorByName('project', $this->t('The login expired. Log in to Quant again.'));
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function submitForm(array &$form, FormStateInterface $form_state) {
+ $uid = (int) $this->currentUser()->id();
+ $login = $this->tokenStore->getPendingLogin($uid);
+ [$org, $project] = explode(':', (string) $form_state->getValue('project'), 2);
+ $access = (string) $login['tokens']['access_token'];
+
+ $write_token = $this->portal->limitToProject($access, $org, $project)
+ ? $this->portal->fetchWriteToken($access, $org, $project)
+ : NULL;
+ // A limited login cannot choose another project, so it is never reused.
+ $this->tokenStore->deletePendingLogin($uid);
+ if ($write_token === NULL) {
+ $this->messenger()->addError($this->t('Quant did not give this site access to the project. Log in again, or connect with a project token.'));
+ $form_state->setRedirect('quant_api.settings_form');
+ return;
+ }
+
+ $this->auth->saveConnection($login['tokens'], $write_token, $login['user']);
+ $this->configFactory()->getEditable('quant_api.settings')
+ ->set('auth_method', Credentials::METHOD_OAUTH)
+ ->set('api_account', $org)
+ ->set('api_project', $project)
+ ->set('api_token', NULL)
+ ->save();
+ \Drupal::service('router.builder')->setRebuildNeeded();
+
+ $this->messenger()->addStatus($this->t('This site now publishes to the Quant project %project.', ['%project' => $project]));
+ $form_state->setRedirect('quant_api.settings_form');
+ }
+
+ /**
+ * The projects of each approved organisation, grouped by organisation.
+ *
+ * @return array
+ * Organisation name => ["org:project" => project name].
+ */
+ protected function projectOptions(array $login): array {
+ $options = [];
+ foreach ($login['organizations'] as $org => $org_name) {
+ foreach ($this->portal->listProjects((string) $login['tokens']['access_token'], (string) $org) ?? [] as $project) {
+ $options[$org_name][$org . ':' . $project['machine_name']] = $project['name'];
+ }
+ }
+ return $options;
+ }
+
+ /**
+ * The form when no login waits for a project choice.
+ */
+ protected function expired(array $form): array {
+ $form['expired'] = [
+ '#markup' => '' . $this->t('No login waits for a project choice. A login waits @minutes minutes. Log in to Quant again.', [
+ '@minutes' => OAuthTokenStore::PENDING_TTL / 60,
+ ]) . '
',
+ ];
+ return $form + $this->backLink();
+ }
+
+ /**
+ * A link back to the settings page.
+ */
+ protected function backLink(): array {
+ return [
+ 'back' => [
+ '#type' => 'link',
+ '#title' => $this->t('Back to the Quant API settings'),
+ '#url' => Url::fromRoute('quant_api.settings_form'),
+ ],
+ ];
+ }
+
+}
diff --git a/modules/quant_api/src/Form/SettingsForm.php b/modules/quant_api/src/Form/SettingsForm.php
index 674db799..b61a7620 100644
--- a/modules/quant_api/src/Form/SettingsForm.php
+++ b/modules/quant_api/src/Form/SettingsForm.php
@@ -4,14 +4,22 @@
use Drupal\Core\Form\ConfigFormBase;
use Drupal\Core\Form\FormStateInterface;
-use Drupal\quant_api\Client\QuantClientInterface;
+use Drupal\Core\Url;
+use Drupal\quant_api\Credentials;
use Drupal\quant_api\EndpointValidator;
+use Drupal\quant_api\OAuth\AuthService;
+use Drupal\quant_api\OAuth\OAuthTokenStore;
+use Drupal\quant_api\OAuth\Platform;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
- * Quant configuration form.
+ * Quant API settings: the platform and the connection.
*
- * @see Drupal\Core\Form\ConfigFormBase
+ * Two ways to connect:
+ * - "Log in to Quant": the editor approves the site in the Quant dashboard
+ * and picks a project. The module stores and refreshes the tokens.
+ * - "Connect with a project token": the organisation, project and token
+ * from the dashboard, typed in or set in settings.php (CI, multi-domain).
*/
class SettingsForm extends ConfigFormBase {
@@ -25,19 +33,36 @@ class SettingsForm extends ConfigFormBase {
protected $client;
/**
- * Build the form.
+ * The credentials resolver.
+ *
+ * @var \Drupal\quant_api\Credentials
*/
- public function __construct(QuantClientInterface $client) {
- $this->client = $client;
- }
+ protected $credentials;
+
+ /**
+ * The OAuth service.
+ *
+ * @var \Drupal\quant_api\OAuth\AuthService
+ */
+ protected $auth;
+
+ /**
+ * The OAuth token store.
+ *
+ * @var \Drupal\quant_api\OAuth\OAuthTokenStore
+ */
+ protected $tokenStore;
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
- return new static(
- $container->get('quant_api.client')
- );
+ $instance = parent::create($container);
+ $instance->client = $container->get('quant_api.client');
+ $instance->credentials = $container->get('quant_api.credentials');
+ $instance->auth = $container->get('quant_api.oauth');
+ $instance->tokenStore = $container->get('quant_api.oauth_store');
+ return $instance;
}
/**
@@ -51,9 +76,7 @@ public function getFormId() {
* {@inheritdoc}
*/
public function getEditableConfigNames() {
- return [
- self::SETTINGS,
- ];
+ return [self::SETTINGS];
}
/**
@@ -61,115 +84,294 @@ public function getEditableConfigNames() {
*/
public function buildForm(array $form, FormStateInterface $form_state) {
$config = $this->config(self::SETTINGS);
+ $this->showStatus();
- if ($config->get('api_token')) {
- if ($project = $this->client->ping()) {
- $message = $this->t('QuantAPI status: Successfully connected to project @project', ['@project' => $config->get('api_project')]);
- \Drupal::messenger()->addMessage($message);
- }
- else {
- \Drupal::messenger()->addError($this->t('QuantAPI error: Unable to connect to the Quant API please check the endpoint on the Integrations page in the Quant dashboard.'));
- }
+ $form['platform'] = [
+ '#type' => 'radios',
+ '#title' => $this->t('Platform'),
+ '#options' => [
+ Platform::QUANTCDN => $this->t('QuantCDN'),
+ Platform::QUANTGOV => $this->t('QuantGov (Australian government)'),
+ ],
+ '#default_value' => Platform::isKnown($config->get('platform')) ? $config->get('platform') : Platform::fromEndpoint($config->get('api_endpoint')),
+ '#description' => $this->t('Your Quant account is on one platform. QuantGov accounts sign in at dash.quantgov.cloud.'),
+ ];
+
+ $form['login'] = $this->buildLogin($config);
+ $form['token'] = $this->buildTokenConnect($config);
+ $form['advanced'] = $this->buildAdvanced($config);
+ $this->showOverrides($form);
+
+ return parent::buildForm($form, $form_state);
+ }
+
+ /**
+ * The "Log in to Quant" section.
+ */
+ protected function buildLogin($config): array {
+ $section = [
+ '#type' => 'fieldset',
+ '#title' => $this->t('Log in to Quant'),
+ ];
+ if ($this->credentials->usesOauth($config) && $this->tokenStore->isConnected()) {
+ return $section + $this->buildConnected($config);
+ }
+ $section['help'] = [
+ '#markup' => '' . $this->t('Approve this site in the Quant dashboard and choose its project. You do not copy a token.') . '
',
+ ];
+ $multi_domain = $this->credentials->hasDomainOverrides();
+ $section['login'] = [
+ '#type' => 'submit',
+ '#value' => $this->t('Log in to Quant'),
+ '#submit' => ['::submitLogin'],
+ '#limit_validation_errors' => [['platform'], ['api_endpoint']],
+ '#disabled' => $multi_domain,
+ '#button_type' => 'primary',
+ ];
+ if ($multi_domain) {
+ $section['multi_domain'] = [
+ '#markup' => '' . $this->t('This site publishes several domains to several projects. Log in to Quant supports one project per site for now, so connect each domain with a project token.') . '
',
+ ];
}
+ return $section;
+ }
- $form['api_endpoint'] = [
- '#type' => 'textfield',
- '#title' => $this->t('API Endpoint'),
- '#description' => $this->t('The fully-qualified domain name for the API endpoint, e.g. https://api.quantcdn.io or the QuantGov endpoint from the Integrations page. Use https and do not add /v1; the module adds it.'),
- '#default_value' => $config->get('api_endpoint') ?: 'https://api.quantcdn.io',
- '#required' => TRUE,
+ /**
+ * The status and the actions of a working OAuth connection.
+ */
+ protected function buildConnected($config): array {
+ $user = (array) $this->tokenStore->get(OAuthTokenStore::USER);
+ $items['status'] = [
+ '#markup' => '' . $this->t('Logged in as %user. This site publishes to the project %project in the organisation %org, and it can reach only that project.', [
+ '%user' => ($user['email'] ?? '') ?: ($user['name'] ?? '-'),
+ '%project' => $config->get('api_project'),
+ '%org' => $config->get('api_account'),
+ ]) . '
',
+ ];
+ $reason = $this->auth->getReconnectReason();
+ if ($reason !== NULL) {
+ $items['reconnect'] = [
+ '#markup' => '' . $this->reconnectMessage($reason) . '
',
+ ];
+ }
+ $items['change'] = [
+ '#type' => 'link',
+ '#title' => $this->t('Change project'),
+ '#url' => Url::fromRoute('quant_api.oauth_connect'),
+ '#attributes' => ['class' => ['button', 'button--primary']],
+ ];
+ $items['logout'] = [
+ '#type' => 'link',
+ '#title' => $this->t('Log out of Quant'),
+ '#url' => Url::fromRoute('quant_api.oauth_disconnect'),
+ '#attributes' => ['class' => ['button', 'button--danger']],
];
+ $items['change_help'] = [
+ '#markup' => '' . $this->t('Change project starts a new login. The current project stays connected until you choose the new one.') . '
',
+ ];
+ return $items;
+ }
+
+ /**
+ * The message for a connection that needs a new login.
+ */
+ protected function reconnectMessage(string $reason) {
+ return match ($reason) {
+ AuthService::RECONNECT_OTHER_SITE => $this->t('The stored login belongs to another copy of this site (for example a database copied from production). This copy does not refresh it. Log in to Quant again on this copy.'),
+ AuthService::RECONNECT_REFUSED => $this->t('Quant refused to refresh the login. Content still publishes. Log in to Quant again to keep the connection current.'),
+ default => $this->t('The login expired. Content still publishes. Log in to Quant again to keep the connection current.'),
+ };
+ }
+ /**
+ * The "Connect with a project token" section.
+ */
+ protected function buildTokenConnect($config): array {
+ $oauth = $this->credentials->usesOauth($config);
+ $section = [
+ '#type' => 'details',
+ '#title' => $this->t('Connect with a project token'),
+ '#open' => !$oauth,
+ '#description' => $oauth
+ ? $this->t('Saving a token here logs this site out of Quant and uses the token instead.')
+ : $this->t('For CI, multi-domain sites and existing sites. Copy the values from the Integrations page in the Quant dashboard.'),
+ ];
// @todo Switch from 'api_account' to 'api_organization'.
- $form['api_account'] = [
+ $section['api_account'] = [
'#type' => 'textfield',
'#title' => $this->t('API Organization'),
'#description' => $this->t('The API organization. This is shown on the Integrations page and with the account information.'),
'#default_value' => $config->get('api_account'),
- '#required' => TRUE,
+ '#parents' => ['api_account'],
];
-
- $form['api_project'] = [
+ $section['api_project'] = [
'#type' => 'textfield',
'#title' => $this->t('API Project'),
'#description' => $this->t('The API project. This is the "API name" shown on the Projects page and the "Project" shown on the Integrations page. Note, this value may be different than the human-readable project name.'),
'#default_value' => $config->get('api_project'),
- '#required' => TRUE,
+ '#parents' => ['api_project'],
];
-
- $form['api_token'] = [
+ $section['api_token'] = [
'#type' => 'password',
'#title' => $this->t('API Token'),
- '#description' => $this->t('The API token. Use the clipboard icon in the dashboard to copy the token from the Projects or Integrations page. Be careful with this information. It should be treated like any system password.'),
- '#default_value' => $config->get('api_token'),
- '#required' => TRUE,
+ '#description' => $this->t('The API token. Use the clipboard icon in the dashboard to copy the token from the Projects or Integrations page. Treat it like a password. Leave it empty to keep the saved token.'),
+ '#parents' => ['api_token'],
];
+ return $section;
+ }
- $form['api_tls_disabled'] = [
- '#type' => 'checkbox',
- '#title' => $this->t('Disable TLS verification'),
- '#description' => $this->t('You can optionally disable TLS verification for all Quant API requests. This is not recommended, but may be necessary in some configurations. For example, old web servers may have issues validating modern TSL/SSL certificates.'),
- '#default_value' => $config->get('api_tls_disabled') ?? FALSE,
+ /**
+ * The advanced section: a custom endpoint and TLS.
+ */
+ protected function buildAdvanced($config): array {
+ return [
+ '#type' => 'details',
+ '#title' => $this->t('Advanced'),
+ 'api_endpoint' => [
+ '#type' => 'textfield',
+ '#title' => $this->t('API Endpoint'),
+ '#description' => $this->t('Leave the platform default unless Quant support gives you another endpoint. Use https and do not add /v1; the module adds it.'),
+ '#default_value' => $config->get('api_endpoint') ?: Platform::apiEndpoint(Platform::QUANTCDN),
+ '#parents' => ['api_endpoint'],
+ ],
+ 'api_tls_disabled' => [
+ '#type' => 'checkbox',
+ '#title' => $this->t('Disable TLS verification'),
+ '#description' => $this->t('You can optionally disable TLS verification for all Quant API requests. This is not recommended, but may be necessary in some configurations. For example, old web servers may have issues validating modern TSL/SSL certificates.'),
+ '#default_value' => $config->get('api_tls_disabled') ?? FALSE,
+ '#parents' => ['api_tls_disabled'],
+ ],
];
+ }
+
+ /**
+ * Shows the connection status as a message.
+ */
+ protected function showStatus(): void {
+ if (!$this->credentials->hasToken()) {
+ return;
+ }
+ if ($this->client->ping()) {
+ $this->messenger()->addStatus($this->t('Connected to the Quant project %project.', ['%project' => $this->client->getProject()]));
+ return;
+ }
+ $this->messenger()->addError($this->t('The Quant API did not accept the connection. Check the platform and the project, or log in to Quant again.'));
+ }
- // API values might be overridden in the settings file.
+ /**
+ * Marks the values that settings.php overrides.
+ */
+ protected function showOverrides(array &$form): void {
$overrides = $this->client->getOverrides();
foreach ($overrides as $key => $value) {
- if ($key === 'api_token') {
- // Don't show the token in the UI.
- $message = $this->t('QuantAPI override: api_token has been overridden in the settings file.');
- }
- else {
- $message = $this->t('QuantAPI override: @key has been overridden in the settings file with @value.',
- [
- '@key' => $key,
- '@value' => $value,
- ]);
- }
-
- // Show warning and add to description.
- \Drupal::messenger()->addWarning($message);
- $form[$key]['#description'] = $form[$key]['#description'] . ' ' . $message . '';
+ $message = $key === 'api_token'
+ ? $this->t('QuantAPI override: api_token has been overridden in the settings file.')
+ : $this->t('QuantAPI override: @key has been overridden in the settings file with @value.', [
+ '@key' => $key,
+ '@value' => $value,
+ ]);
+ $this->messenger()->addWarning($message);
}
-
- // Show error if not using TSL verification.
- if ((isset($overrides['api_tls_disabled']) && $overrides['api_tls_disabled']) || $config->get('api_tls_disabled')) {
- \Drupal::messenger()->addError($this->t('DANGER ZONE: TLS verification is disabled for Quant API connections. It is highly recommended that you update your server configuration to handle TLS rather than disabling TLS verification.'));
+ if (!empty($overrides['api_tls_disabled']) || $this->config(self::SETTINGS)->get('api_tls_disabled')) {
+ $this->messenger()->addError($this->t('DANGER ZONE: TLS verification is disabled for Quant API connections. It is highly recommended that you update your server configuration to handle TLS rather than disabling TLS verification.'));
}
-
- return parent::buildForm($form, $form_state);
}
/**
* {@inheritdoc}
*/
public function validateForm(array &$form, FormStateInterface $form_state) {
+ $this->validateEndpoint($form_state);
+ if (in_array('::submitLogin', $form_state->getTriggeringElement()['#submit'] ?? [], TRUE)) {
+ // The login button validates only the platform and the endpoint.
+ return;
+ }
+ $this->validateTokenConnect($form_state);
+ parent::validateForm($form, $form_state);
+ }
+
+ /**
+ * Validates the endpoint (empty means the platform default).
+ */
+ protected function validateEndpoint(FormStateInterface $form_state): void {
+ $endpoint = $this->resolveEndpoint($form_state);
$messages = [
'not_https' => $this->t('The API endpoint must be an https URL, for example https://api.quantcdn.io.'),
'not_origin' => $this->t('The API endpoint must contain only the scheme and host, for example https://api.quantcdn.io.'),
'has_v1' => $this->t('Remove /v1 from the API endpoint. The module adds it.'),
];
- $error = EndpointValidator::validate($form_state->getValue('api_endpoint'));
+ $error = EndpointValidator::validate($endpoint);
if ($error !== NULL) {
$form_state->setErrorByName('api_endpoint', $messages[$error]);
}
- parent::validateForm($form, $form_state);
+ }
+
+ /**
+ * Requires the organisation, project and token for token connect.
+ */
+ protected function validateTokenConnect(FormStateInterface $form_state): void {
+ $config = $this->config(self::SETTINGS);
+ $new_token = (string) $form_state->getValue('api_token') !== '';
+ $token_connect = !$this->credentials->usesOauth($config) || $new_token;
+ if (!$token_connect) {
+ return;
+ }
+ foreach (['api_account' => $this->t('API Organization'), 'api_project' => $this->t('API Project')] as $key => $label) {
+ if (trim((string) $form_state->getValue($key)) === '') {
+ $form_state->setErrorByName($key, $this->t('@field is required to connect with a project token.', ['@field' => $label]));
+ }
+ }
+ $has_saved = (string) $config->get('api_token') !== '' && !$this->credentials->usesOauth($config);
+ if (!$new_token && !$has_saved && !$config->hasOverrides('api_token')) {
+ $form_state->setErrorByName('api_token', $this->t('API Token is required to connect with a project token.'));
+ }
+ }
+
+ /**
+ * The endpoint to save: the platform default unless a custom one is set.
+ *
+ * A value that equals the default of any platform follows the chosen
+ * platform, so switching the platform switches the endpoint.
+ */
+ protected function resolveEndpoint(FormStateInterface $form_state): string {
+ $platform = (string) $form_state->getValue('platform');
+ $platform = Platform::isKnown($platform) ? $platform : Platform::QUANTCDN;
+ $endpoint = EndpointValidator::normalise($form_state->getValue('api_endpoint'));
+ $defaults = array_map([Platform::class, 'apiEndpoint'], Platform::ids());
+ return ($endpoint === '' || in_array($endpoint, $defaults, TRUE)) ? Platform::apiEndpoint($platform) : $endpoint;
+ }
+
+ /**
+ * Saves the platform and starts the login.
+ */
+ public function submitLogin(array &$form, FormStateInterface $form_state) {
+ $this->savePlatform($form_state)->save();
+ // A redirect is not rendered, so the CSRF placeholder of a generated URL
+ // is never replaced. Add the real token for the route's path.
+ $url = Url::fromRoute('quant_api.oauth_connect');
+ $url->setOption('query', ['token' => \Drupal::csrfToken()->get($url->getInternalPath())]);
+ $form_state->setRedirectUrl($url);
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
+ $this->logOutOnPlatformChange($form_state);
+ $settings = $this->savePlatform($form_state)
+ ->set('api_tls_disabled', (bool) $form_state->getValue('api_tls_disabled'));
- // Retrieve the configuration.
- // @todo Switch from 'api_account' to 'api_organization'.
- $this->configFactory->getEditable(self::SETTINGS)
- ->set('api_endpoint', EndpointValidator::normalise($form_state->getValue('api_endpoint')))
- ->set('api_token', $form_state->getValue('api_token'))
- ->set('api_project', $form_state->getValue('api_project'))
- ->set('api_account', $form_state->getValue('api_account'))
- ->set('api_tls_disabled', $form_state->getValue('api_tls_disabled'))
- ->save();
+ $new_token = (string) $form_state->getValue('api_token');
+ if ($new_token !== '' || !$this->credentials->usesOauth($settings)) {
+ $settings->set('api_project', trim((string) $form_state->getValue('api_project')))
+ ->set('api_account', trim((string) $form_state->getValue('api_account')));
+ }
+ if ($new_token !== '') {
+ // A typed token replaces a Quant login.
+ $this->auth->disconnect();
+ $settings->set('auth_method', Credentials::METHOD_TOKEN)->set('api_token', $new_token);
+ }
+ $settings->save();
// Clear router cache in case search has been enabled or disabled.
// @todo Need to clear any search autocomplete blocks in case of config change.
@@ -178,4 +380,30 @@ public function submitForm(array &$form, FormStateInterface $form_state) {
parent::submitForm($form, $form_state);
}
+ /**
+ * Logs out when the platform changes: the login belongs to the old one.
+ */
+ protected function logOutOnPlatformChange(FormStateInterface $form_state): void {
+ $config = $this->config(self::SETTINGS);
+ if (!$this->credentials->usesOauth($config) || $config->get('platform') === $form_state->getValue('platform')) {
+ return;
+ }
+ $this->auth->disconnect();
+ $this->configFactory()->getEditable(self::SETTINGS)->set('auth_method', Credentials::METHOD_TOKEN)->save();
+ $this->messenger()->addWarning($this->t('The platform changed, so this site is logged out of Quant. Log in to Quant on the new platform.'));
+ }
+
+ /**
+ * The editable settings with the platform and endpoint set.
+ *
+ * @return \Drupal\Core\Config\Config
+ * The settings, not yet saved.
+ */
+ protected function savePlatform(FormStateInterface $form_state) {
+ $platform = (string) $form_state->getValue('platform');
+ return $this->configFactory()->getEditable(self::SETTINGS)
+ ->set('platform', Platform::isKnown($platform) ? $platform : Platform::QUANTCDN)
+ ->set('api_endpoint', $this->resolveEndpoint($form_state));
+ }
+
}
diff --git a/modules/quant_api/src/OAuth/AuthService.php b/modules/quant_api/src/OAuth/AuthService.php
new file mode 100644
index 00000000..7c158e0b
--- /dev/null
+++ b/modules/quant_api/src/OAuth/AuthService.php
@@ -0,0 +1,540 @@
+logger = $logger_factory->get('quant_api');
+ }
+
+ /**
+ * The platform the site is configured for.
+ */
+ public function getPlatform(): string {
+ $platform = (string) $this->configFactory->get('quant_api.settings')->get('platform');
+ return Platform::isKnown($platform) ? $platform : Platform::QUANTCDN;
+ }
+
+ /**
+ * The dashboard (OAuth server) of the configured platform.
+ */
+ public function getDashboardUrl(): string {
+ return Platform::dashboardUrl($this->getPlatform());
+ }
+
+ /**
+ * A new PKCE code verifier: 32 random bytes, base64url.
+ */
+ public function generatePkceVerifier(): string {
+ return self::base64Url(random_bytes(32));
+ }
+
+ /**
+ * The S256 code challenge of a verifier.
+ */
+ public function computePkceChallenge(string $verifier): string {
+ return self::base64Url(hash('sha256', $verifier, TRUE));
+ }
+
+ /**
+ * The dashboard URL that starts the login.
+ */
+ public function getAuthorizationUrl(string $state, string $redirect_uri, string $code_challenge): string {
+ return $this->getDashboardUrl() . '/oauth/authorize?' . http_build_query([
+ 'client_id' => Platform::CLIENT_ID,
+ 'response_type' => 'code',
+ 'redirect_uri' => $redirect_uri,
+ 'state' => $state,
+ 'scope' => 'full_access',
+ 'code_challenge' => $code_challenge,
+ 'code_challenge_method' => 'S256',
+ ], '', '&', PHP_QUERY_RFC3986);
+ }
+
+ /**
+ * Exchanges an authorization code for tokens.
+ *
+ * @return array|null
+ * The token response, or NULL on any failure (logged without values).
+ */
+ public function exchangeCode(string $code, string $redirect_uri, string $code_verifier): ?array {
+ try {
+ $response = $this->httpClient->request('POST', $this->getDashboardUrl() . '/oauth/token', [
+ 'form_params' => [
+ 'grant_type' => 'authorization_code',
+ 'client_id' => Platform::CLIENT_ID,
+ 'code' => $code,
+ 'redirect_uri' => $redirect_uri,
+ 'code_verifier' => $code_verifier,
+ ],
+ 'headers' => ['Accept' => 'application/json'],
+ 'timeout' => self::REFRESH_HTTP_TIMEOUT,
+ 'http_errors' => FALSE,
+ ]);
+ }
+ catch (\Exception $e) {
+ $this->logger->error('The Quant login could not exchange the code (@class).', ['@class' => get_class($e)]);
+ return NULL;
+ }
+ $data = json_decode((string) $response->getBody(), TRUE);
+ if ($response->getStatusCode() !== 200 || empty($data['access_token'])) {
+ $this->logger->error('The Quant dashboard refused the login code with status @status (@error).', [
+ '@status' => $response->getStatusCode(),
+ '@error' => is_array($data) ? (string) ($data['error'] ?? 'none') : 'none',
+ ]);
+ return NULL;
+ }
+ return $data;
+ }
+
+ /**
+ * Makes a login limited to one project the site's connection.
+ *
+ * @param array $tokens
+ * The token response of the login.
+ * @param string $write_token
+ * The project write token.
+ * @param array $user
+ * The user name and email, for display only.
+ */
+ public function saveConnection(array $tokens, string $write_token, array $user): void {
+ $this->tokenStore->clear();
+ $this->tokenStore->saveTokens($tokens);
+ $this->tokenStore->setMany([
+ OAuthTokenStore::WRITE_TOKEN => $write_token,
+ OAuthTokenStore::USER => array_intersect_key($user, array_flip(['name', 'email'])),
+ OAuthTokenStore::FINGERPRINT => $this->fingerprint->compute(),
+ ]);
+ }
+
+ /**
+ * Deletes the local connection. The portal has no revoke endpoint yet.
+ */
+ public function disconnect(): void {
+ $this->tokenStore->clear();
+ }
+
+ /**
+ * The access token, refreshed when it is about to expire.
+ *
+ * @return string|null
+ * The access token, or NULL when no usable token can be obtained.
+ */
+ public function getValidAccessToken(): ?string {
+ $record = $this->tokenStore->getAll();
+ if (empty($record[OAuthTokenStore::ACCESS_TOKEN])) {
+ return NULL;
+ }
+ $decision = $this->getRefreshDecision($record, self::EXPIRY_MARGIN, FALSE);
+ if ($decision === 'none') {
+ return $record[OAuthTokenStore::ACCESS_TOKEN];
+ }
+ if ($decision === 'defer') {
+ return $this->getUsableAccessToken($record);
+ }
+ return $this->refreshIfAllowed($record, self::EXPIRY_MARGIN);
+ }
+
+ /**
+ * Refreshes from cron 30 days before expiry, or at half a short lifetime.
+ */
+ public function refreshFromCron(): void {
+ $record = $this->tokenStore->getAll();
+ if (empty($record[OAuthTokenStore::REFRESH_TOKEN])) {
+ return;
+ }
+ $margin = $this->getCronMargin($record);
+ if ($this->getRefreshDecision($record, $margin, TRUE) === 'refresh') {
+ $this->refreshIfAllowed($record, $margin);
+ }
+ }
+
+ /**
+ * Why the editor must log in again, or NULL when the connection works.
+ *
+ * @return string|null
+ * One of the RECONNECT_* constants, or NULL.
+ */
+ public function getReconnectReason(): ?string {
+ $record = $this->tokenStore->getAll();
+ if (empty($record[OAuthTokenStore::ACCESS_TOKEN])) {
+ return NULL;
+ }
+ if ($this->isRecordFromAnotherSite($record)) {
+ return self::RECONNECT_OTHER_SITE;
+ }
+ if (!empty($record[OAuthTokenStore::REFUSAL])) {
+ return self::RECONNECT_REFUSED;
+ }
+ return $this->isExpiring($record, 0) ? self::RECONNECT_EXPIRED : NULL;
+ }
+
+ /**
+ * Decides whether to refresh now: 'refresh', 'none' or 'defer'.
+ */
+ protected function getRefreshDecision(array $record, int $margin, bool $cron): string {
+ $pending = $this->getPendingState($record);
+ if ($pending !== 'none') {
+ return $pending === 'retry' ? 'refresh' : 'defer';
+ }
+ if (!$this->isExpiring($record, $margin)) {
+ return 'none';
+ }
+ $name = $cron ? OAuthTokenStore::CRON_RETRY_AFTER : OAuthTokenStore::RETRY_AFTER;
+ return (int) ($record[$name] ?? 0) > time() ? 'defer' : 'refresh';
+ }
+
+ /**
+ * The state of a refresh with no answer: 'none', 'wait' or 'retry'.
+ */
+ protected function getPendingState(array $record): string {
+ $pending = $this->getCurrentPending($record);
+ if ($pending === NULL) {
+ return 'none';
+ }
+ $age = time() - (int) ($pending['since'] ?? 0);
+ if ($age < self::PENDING_MIN_AGE || (int) ($pending['attempts'] ?? 1) > self::PENDING_MAX_RETRIES) {
+ return 'wait';
+ }
+ return 'retry';
+ }
+
+ /**
+ * The pending marker, when it is for the stored refresh token and recent.
+ */
+ protected function getCurrentPending(array $record): ?array {
+ $pending = $record[OAuthTokenStore::PENDING] ?? NULL;
+ $refresh = (string) ($record[OAuthTokenStore::REFRESH_TOKEN] ?? '');
+ if (!is_array($pending) || $refresh === '' || (int) ($pending['since'] ?? 0) < time() - self::PENDING_WINDOW) {
+ return NULL;
+ }
+ return hash_equals((string) ($pending['token'] ?? ''), hash('sha256', $refresh)) ? $pending : NULL;
+ }
+
+ /**
+ * The cron margin: 30 days, or half the lifetime of a shorter token.
+ */
+ protected function getCronMargin(array $record): int {
+ $lifetime = (int) ($record[OAuthTokenStore::LIFETIME] ?? 0);
+ return $lifetime > 0 ? min(self::CRON_MARGIN, intdiv($lifetime, 2)) : self::CRON_MARGIN;
+ }
+
+ /**
+ * Refreshes under the lock unless a blocker or a transaction prevents it.
+ */
+ protected function refreshIfAllowed(array $record, int $margin): ?string {
+ if ($this->getRefreshBlocker($record) !== NULL || $this->database->inTransaction()) {
+ // In a transaction the lock row is not visible to other processes and
+ // a rollback would discard the rotated pair. Cron refreshes later.
+ return $this->getUsableAccessToken($record);
+ }
+ $seen_refresh = (string) $record[OAuthTokenStore::REFRESH_TOKEN];
+ if (!$this->acquireRefreshLock($margin)) {
+ return $this->getUsableAccessToken($this->tokenStore->getAll());
+ }
+ try {
+ return $this->refreshUnderLock($seen_refresh, $margin);
+ }
+ finally {
+ $this->lock->release(self::REFRESH_LOCK_ID);
+ }
+ }
+
+ /**
+ * Why a refresh must not be sent, or NULL.
+ */
+ protected function getRefreshBlocker(array $record): ?string {
+ if (empty($record[OAuthTokenStore::REFRESH_TOKEN])) {
+ return self::RECONNECT_EXPIRED;
+ }
+ if ($this->isRecordFromAnotherSite($record)) {
+ return self::RECONNECT_OTHER_SITE;
+ }
+ return empty($record[OAuthTokenStore::REFUSAL]) ? NULL : self::RECONNECT_REFUSED;
+ }
+
+ /**
+ * Takes the refresh lock, or waits for a peer that has it.
+ */
+ protected function acquireRefreshLock(int $margin): bool {
+ if ($this->lock->acquire(self::REFRESH_LOCK_ID, self::REFRESH_LOCK_TIMEOUT)) {
+ return TRUE;
+ }
+ $this->lock->wait(self::REFRESH_LOCK_ID, self::REFRESH_LOCK_WAIT);
+ if (!$this->isExpiring($this->tokenStore->getAll(), $margin)) {
+ return FALSE;
+ }
+ return $this->lock->acquire(self::REFRESH_LOCK_ID, self::REFRESH_LOCK_TIMEOUT);
+ }
+
+ /**
+ * Re-reads the store under the lock and refreshes if still needed.
+ */
+ protected function refreshUnderLock(string $seen_refresh, int $margin): ?string {
+ $record = $this->tokenStore->getAll();
+ if (!$this->isRefreshStillNeeded($seen_refresh, $record, $margin)) {
+ return $this->getUsableAccessToken($record);
+ }
+ $result = $this->sendRefresh((string) $record[OAuthTokenStore::REFRESH_TOKEN]);
+ if ($result['tokens']) {
+ return $this->saveRotatedTokens($result['tokens'], $record);
+ }
+ if ($result['outcome'] === 'refused') {
+ $this->recordRefusal($result['error']);
+ }
+ return $this->getUsableAccessToken($this->tokenStore->getAll());
+ }
+
+ /**
+ * Whether the refresh is still needed after the lock was taken.
+ */
+ protected function isRefreshStillNeeded(string $seen_refresh, array $record, int $margin): bool {
+ $refresh = (string) ($record[OAuthTokenStore::REFRESH_TOKEN] ?? '');
+ if ($refresh === '' || !hash_equals($seen_refresh, $refresh) || $this->getRefreshBlocker($record) !== NULL) {
+ return FALSE;
+ }
+ $pending = $this->getPendingState($record);
+ if ($pending !== 'none') {
+ return $pending === 'retry';
+ }
+ return $this->isExpiring($record, $margin);
+ }
+
+ /**
+ * Sends the refresh grant once, with the pending marker and back-off.
+ *
+ * @return array
+ * tokens (or NULL), outcome ('ok', 'rotated', 'refused', 'unknown' or
+ * 'failed') and error.
+ */
+ protected function sendRefresh(string $refresh_token): array {
+ $this->keepRunning();
+ $previous = $this->getCurrentPending([
+ OAuthTokenStore::PENDING => $this->tokenStore->get(OAuthTokenStore::PENDING),
+ OAuthTokenStore::REFRESH_TOKEN => $refresh_token,
+ ]);
+ $this->tokenStore->set(OAuthTokenStore::PENDING, [
+ 'since' => $previous ? (int) $previous['since'] : time(),
+ 'token' => hash('sha256', $refresh_token),
+ 'attempts' => $previous ? (int) ($previous['attempts'] ?? 1) + 1 : 1,
+ ]);
+ $result = $this->requestRefresh($refresh_token);
+ if (in_array($result['outcome'], ['refused', 'failed'], TRUE)) {
+ $this->tokenStore->delete([OAuthTokenStore::PENDING]);
+ }
+ if (in_array($result['outcome'], ['unknown', 'rotated'], TRUE)) {
+ $this->tokenStore->setMany([
+ OAuthTokenStore::RETRY_AFTER => time() + self::REQUEST_BACKOFF,
+ OAuthTokenStore::CRON_RETRY_AFTER => time() + self::CRON_BACKOFF,
+ ]);
+ }
+ return $result;
+ }
+
+ /**
+ * Posts the refresh grant and classifies the answer.
+ */
+ protected function requestRefresh(string $refresh_token): array {
+ try {
+ $response = $this->httpClient->request('POST', $this->getDashboardUrl() . '/oauth/token', [
+ 'form_params' => [
+ 'grant_type' => 'refresh_token',
+ 'refresh_token' => $refresh_token,
+ 'client_id' => Platform::CLIENT_ID,
+ ],
+ 'headers' => ['Accept' => 'application/json'],
+ 'timeout' => self::REFRESH_HTTP_TIMEOUT,
+ 'http_errors' => FALSE,
+ ]);
+ }
+ catch (\Exception $e) {
+ // A timeout can come after the portal committed the rotation.
+ $this->logger->error('The Quant token refresh got no answer (@class). The stored tokens are kept.', ['@class' => get_class($e)]);
+ return ['tokens' => NULL, 'outcome' => 'unknown', 'error' => ''];
+ }
+ return $this->parseRefreshResponse($response);
+ }
+
+ /**
+ * Turns a refresh response into a result array.
+ */
+ protected function parseRefreshResponse(ResponseInterface $response): array {
+ $status = $response->getStatusCode();
+ $data = json_decode((string) $response->getBody(), TRUE);
+ $data = is_array($data) ? $data : [];
+ if ($status === 200 && !empty($data['access_token'])) {
+ return ['tokens' => $data, 'outcome' => 'ok', 'error' => ''];
+ }
+ $error = (string) ($data['error'] ?? '');
+ $description = mb_substr((string) ($data['error_description'] ?? ''), 0, 200);
+ $outcome = self::classifyRefreshError($status, $error, $description);
+ if ($outcome !== 'refused') {
+ $this->logger->log($outcome === 'rotated' ? LogLevel::NOTICE : LogLevel::ERROR, 'The Quant token refresh was rejected with status @status (@error). The stored tokens are kept.', [
+ '@status' => $status,
+ '@error' => $error ?: 'none',
+ ]);
+ }
+ return ['tokens' => NULL, 'outcome' => $outcome, 'error' => $error];
+ }
+
+ /**
+ * Classifies a refresh error: 'rotated', 'refused', 'unknown' or 'failed'.
+ */
+ public static function classifyRefreshError(int $status, string $error, string $description): string {
+ if ($error === 'invalid_grant' && stripos($description, 'just rotated') !== FALSE) {
+ return 'rotated';
+ }
+ if (in_array($error, ['invalid_grant', 'invalid_client'], TRUE)) {
+ return 'refused';
+ }
+ return ($status >= 500 || $status === 200) ? 'unknown' : 'failed';
+ }
+
+ /**
+ * Keeps the process alive so a rotated pair is always saved.
+ */
+ protected function keepRunning(): void {
+ if (function_exists('ignore_user_abort')) {
+ ignore_user_abort(TRUE);
+ }
+ Environment::setTimeLimit(0);
+ }
+
+ /**
+ * Saves a rotated pair and clears the refresh markers.
+ */
+ protected function saveRotatedTokens(array $token_data, array $previous): ?string {
+ try {
+ $this->tokenStore->saveTokens($token_data);
+ $this->tokenStore->delete([
+ OAuthTokenStore::PENDING,
+ OAuthTokenStore::RETRY_AFTER,
+ OAuthTokenStore::CRON_RETRY_AFTER,
+ ]);
+ }
+ catch (\Throwable $e) {
+ $this->logger->critical('The refreshed Quant tokens could not be saved (@class). The refresh is retried later.', ['@class' => get_class($e)]);
+ return $this->getUsableAccessToken($previous);
+ }
+ return $token_data['access_token'];
+ }
+
+ /**
+ * Records a final refusal: no more refresh until the next login.
+ */
+ protected function recordRefusal(string $error): void {
+ $this->tokenStore->set(OAuthTokenStore::REFUSAL, ['error' => $error, 'at' => time()]);
+ $this->logger->error('Quant refused the token refresh (@error). Log in to Quant again on the Quant API settings page.', ['@error' => $error]);
+ }
+
+ /**
+ * The stored access token while it has not expired.
+ */
+ protected function getUsableAccessToken(array $record): ?string {
+ $access = $record[OAuthTokenStore::ACCESS_TOKEN] ?? NULL;
+ if (!$access || $this->isExpiring($record, 0)) {
+ return NULL;
+ }
+ return $access;
+ }
+
+ /**
+ * Whether the stored tokens were issued to another site.
+ */
+ protected function isRecordFromAnotherSite(array $record): bool {
+ $stored = $record[OAuthTokenStore::FINGERPRINT] ?? NULL;
+ return is_array($stored) && $this->fingerprint->differences($stored) !== [];
+ }
+
+ /**
+ * Whether the access token expires within the margin.
+ */
+ protected function isExpiring(array $record, int $margin): bool {
+ $expires_at = (int) ($record[OAuthTokenStore::EXPIRES_AT] ?? 0);
+ return $expires_at > 0 && $expires_at - $margin <= time();
+ }
+
+ /**
+ * Base64url without padding.
+ */
+ protected static function base64Url(string $bytes): string {
+ return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '=');
+ }
+
+}
diff --git a/modules/quant_api/src/OAuth/OAuthTokenStore.php b/modules/quant_api/src/OAuth/OAuthTokenStore.php
new file mode 100644
index 00000000..eb9e1858
--- /dev/null
+++ b/modules/quant_api/src/OAuth/OAuthTokenStore.php
@@ -0,0 +1,191 @@
+store = $key_value_factory->get(self::COLLECTION);
+ $this->pending = $expirable_factory->get(self::PENDING_COLLECTION);
+ }
+
+ /**
+ * All stored connection values, keyed by name.
+ */
+ public function getAll(): array {
+ return $this->store->getMultiple(self::NAMES);
+ }
+
+ /**
+ * One stored connection value.
+ */
+ public function get(string $name): mixed {
+ return $this->store->get($name);
+ }
+
+ /**
+ * Sets one connection value.
+ */
+ public function set(string $name, mixed $value): void {
+ $this->store->set($name, $value);
+ }
+
+ /**
+ * Sets several connection values.
+ */
+ public function setMany(array $values): void {
+ $this->store->setMultiple($values);
+ }
+
+ /**
+ * Deletes the named connection values.
+ */
+ public function delete(array $names): void {
+ $this->store->deleteMultiple($names);
+ }
+
+ /**
+ * Whether a connection (a limited token and a write token) is stored.
+ */
+ public function isConnected(): bool {
+ return (string) $this->store->get(self::WRITE_TOKEN) !== '';
+ }
+
+ /**
+ * Saves an access and refresh token from a token response.
+ *
+ * @param array $token_data
+ * The decoded token response: access_token, refresh_token, expires_in.
+ */
+ public function saveTokens(array $token_data): void {
+ $values = [self::ACCESS_TOKEN => (string) $token_data['access_token']];
+ if (!empty($token_data['refresh_token'])) {
+ $values[self::REFRESH_TOKEN] = (string) $token_data['refresh_token'];
+ }
+ $expires_in = (int) ($token_data['expires_in'] ?? 0);
+ if ($expires_in > 0) {
+ $values[self::EXPIRES_AT] = time() + $expires_in;
+ $values[self::LIFETIME] = $expires_in;
+ }
+ $this->store->setMultiple($values);
+ if ($expires_in <= 0) {
+ $this->store->deleteMultiple([self::EXPIRES_AT, self::LIFETIME]);
+ }
+ }
+
+ /**
+ * Deletes the whole connection.
+ */
+ public function clear(): void {
+ $this->store->deleteMultiple(self::NAMES);
+ }
+
+ /**
+ * Keeps a login that waits for the project choice.
+ *
+ * @param int $uid
+ * The user who logged in.
+ * @param array $login
+ * The token response, the user and the granted organisations.
+ */
+ public function savePendingLogin(int $uid, array $login): void {
+ $this->pending->setWithExpire((string) $uid, $login, self::PENDING_TTL);
+ }
+
+ /**
+ * The login that waits for this user's project choice, if any.
+ */
+ public function getPendingLogin(int $uid): ?array {
+ $login = $this->pending->get((string) $uid);
+ return is_array($login) ? $login : NULL;
+ }
+
+ /**
+ * Forgets a pending login.
+ */
+ public function deletePendingLogin(int $uid): void {
+ $this->pending->delete((string) $uid);
+ }
+
+ /**
+ * Forgets every pending login (uninstall).
+ */
+ public function deleteAllPendingLogins(): void {
+ $this->pending->deleteAll();
+ }
+
+}
diff --git a/modules/quant_api/src/OAuth/Platform.php b/modules/quant_api/src/OAuth/Platform.php
new file mode 100644
index 00000000..ecf18db4
--- /dev/null
+++ b/modules/quant_api/src/OAuth/Platform.php
@@ -0,0 +1,88 @@
+ [
+ 'api' => 'https://api.quantcdn.io',
+ 'dashboard' => 'https://dashboard.quantcdn.io',
+ ],
+ self::QUANTGOV => [
+ 'api' => 'https://api.quantgov.cloud',
+ 'dashboard' => 'https://dash.quantgov.cloud',
+ ],
+ ];
+
+ /**
+ * The OAuth client id of this module on every platform.
+ */
+ public const CLIENT_ID = 'drupal-quant';
+
+ /**
+ * The known platform ids.
+ *
+ * @return string[]
+ * The ids.
+ */
+ public static function ids(): array {
+ return array_keys(self::PLATFORMS);
+ }
+
+ /**
+ * Whether the id names a known platform.
+ */
+ public static function isKnown(?string $platform): bool {
+ return isset(self::PLATFORMS[(string) $platform]);
+ }
+
+ /**
+ * The content API endpoint of a platform, without /v1.
+ */
+ public static function apiEndpoint(string $platform): string {
+ return self::PLATFORMS[$platform]['api'] ?? self::PLATFORMS[self::QUANTCDN]['api'];
+ }
+
+ /**
+ * The dashboard (OAuth server) of a platform.
+ *
+ * A $settings['quant_api_dashboard_url'] override wins, for local and
+ * staging dashboards.
+ */
+ public static function dashboardUrl(string $platform): string {
+ $override = (string) Settings::get('quant_api_dashboard_url', '');
+ if ($override !== '') {
+ return rtrim($override, '/');
+ }
+ return self::PLATFORMS[$platform]['dashboard'] ?? self::PLATFORMS[self::QUANTCDN]['dashboard'];
+ }
+
+ /**
+ * The platform of an API endpoint, for sites configured before 2.3.
+ *
+ * @return string
+ * The platform id. An endpoint on quantgov.cloud is QuantGov; any other
+ * endpoint is QuantCDN.
+ */
+ public static function fromEndpoint(?string $endpoint): string {
+ $host = (string) parse_url((string) $endpoint, PHP_URL_HOST);
+ return ($host === 'quantgov.cloud' || str_ends_with($host, '.quantgov.cloud')) ? self::QUANTGOV : self::QUANTCDN;
+ }
+
+}
diff --git a/modules/quant_api/src/OAuth/PortalClient.php b/modules/quant_api/src/OAuth/PortalClient.php
new file mode 100644
index 00000000..ea2cb60e
--- /dev/null
+++ b/modules/quant_api/src/OAuth/PortalClient.php
@@ -0,0 +1,146 @@
+logger = $logger_factory->get('quant_api');
+ }
+
+ /**
+ * The user and the organisations the login granted.
+ *
+ * @return array|null
+ * The user (name, email, organizations), or NULL on failure.
+ */
+ public function getUser(string $access_token): ?array {
+ $data = $this->send('GET', '/api/oauth/user', $access_token);
+ return is_array($data) ? ($data['user'] ?? $data) : NULL;
+ }
+
+ /**
+ * The projects of one organisation.
+ *
+ * @return array|null
+ * A list of ['machine_name' => ..., 'name' => ...], or NULL on failure.
+ */
+ public function listProjects(string $access_token, string $organization): ?array {
+ if (!self::isMachineName($organization)) {
+ return NULL;
+ }
+ $data = $this->send('GET', '/api/v2/organizations/' . $organization . '/projects', $access_token);
+ if (!is_array($data)) {
+ return NULL;
+ }
+ $projects = [];
+ foreach ($data as $project) {
+ if (is_array($project) && self::isMachineName((string) ($project['machine_name'] ?? ''))) {
+ $projects[] = [
+ 'machine_name' => (string) $project['machine_name'],
+ 'name' => (string) ($project['name'] ?? $project['machine_name']),
+ ];
+ }
+ }
+ return $projects;
+ }
+
+ /**
+ * Limits the token to one project. The limit cannot be widened later.
+ */
+ public function limitToProject(string $access_token, string $organization, string $project): bool {
+ if (!self::isMachineName($organization) || !self::isMachineName($project)) {
+ return FALSE;
+ }
+ $data = $this->send('POST', '/api/oauth/token/project', $access_token, [
+ 'organization' => $organization,
+ 'project' => $project,
+ ]);
+ return is_array($data) && ($data['project'] ?? NULL) === $project;
+ }
+
+ /**
+ * The write token of one project, for the content API.
+ */
+ public function fetchWriteToken(string $access_token, string $organization, string $project): ?string {
+ if (!self::isMachineName($organization) || !self::isMachineName($project)) {
+ return NULL;
+ }
+ $data = $this->send('GET', '/api/v2/organizations/' . $organization . '/projects/' . $project . '?with_token=1', $access_token);
+ $token = is_array($data) ? (string) ($data['write_token'] ?? '') : '';
+ return $token !== '' ? $token : NULL;
+ }
+
+ /**
+ * Whether a value is a safe organisation or project machine name.
+ */
+ public static function isMachineName(string $value): bool {
+ return preg_match(self::MACHINE_NAME, $value) === 1;
+ }
+
+ /**
+ * Sends one call and returns the decoded data, or NULL on any failure.
+ */
+ protected function send(string $method, string $path, string $access_token, ?array $json = NULL): ?array {
+ $options = [
+ 'headers' => [
+ 'Authorization' => 'Bearer ' . $access_token,
+ 'Accept' => 'application/json',
+ ],
+ 'timeout' => self::TIMEOUT,
+ 'http_errors' => FALSE,
+ ];
+ if ($json !== NULL) {
+ $options['json'] = $json;
+ }
+ try {
+ $response = $this->httpClient->request($method, $this->auth->getDashboardUrl() . $path, $options);
+ }
+ catch (\Exception $e) {
+ $this->logger->error('The Quant dashboard call @method @path failed (@class).', [
+ '@method' => $method,
+ '@path' => strtok($path, '?'),
+ '@class' => get_class($e),
+ ]);
+ return NULL;
+ }
+ $data = json_decode((string) $response->getBody(), TRUE);
+ if ($response->getStatusCode() !== 200 || !is_array($data)) {
+ $this->logger->warning('The Quant dashboard call @method @path returned status @status.', [
+ '@method' => $method,
+ '@path' => strtok($path, '?'),
+ '@status' => $response->getStatusCode(),
+ ]);
+ return NULL;
+ }
+ return isset($data['data']) && is_array($data['data']) ? $data['data'] : $data;
+ }
+
+}
diff --git a/modules/quant_api/src/OAuth/SiteFingerprint.php b/modules/quant_api/src/OAuth/SiteFingerprint.php
new file mode 100644
index 00000000..e16dbc32
--- /dev/null
+++ b/modules/quant_api/src/OAuth/SiteFingerprint.php
@@ -0,0 +1,104 @@
+ $site_id] : $this->automaticValues();
+ $values['uuid'] = (string) $this->configFactory->get('system.site')->get('uuid');
+
+ $hashes = [];
+ foreach ($values as $name => $value) {
+ $hashes[$name] = hash('sha256', $name . "\0" . $value);
+ }
+ return $hashes;
+ }
+
+ /**
+ * The components that differ between a stored fingerprint and this site.
+ *
+ * @return string[]
+ * The names of the differing components; empty when it is this site.
+ */
+ public function differences(array $stored): array {
+ $current = $this->compute();
+ $different = [];
+ foreach (array_keys($stored + $current) as $name) {
+ if ($this->differs($name, $stored, $current)) {
+ $different[] = $name;
+ }
+ }
+ return $different;
+ }
+
+ /**
+ * Whether one component differs.
+ *
+ * A hosting variable counts only when both sides have it, so a variable
+ * set for one context only (web, not CLI) does not split one site in two.
+ */
+ protected function differs(string $name, array $stored, array $current): bool {
+ if (str_starts_with($name, 'env:') && (!isset($stored[$name]) || !isset($current[$name]))) {
+ return FALSE;
+ }
+ return !hash_equals((string) ($stored[$name] ?? ''), (string) ($current[$name] ?? ''));
+ }
+
+ /**
+ * The automatic components: salt, database, prefix and hosting variables.
+ */
+ protected function automaticValues(): array {
+ $db = Database::getConnectionInfo()['default'] ?? [];
+ $prefix = $db['prefix'] ?? '';
+ $values = [
+ 'hash_salt' => (string) Settings::get('hash_salt', ''),
+ 'database' => (string) ($db['database'] ?? ''),
+ 'prefix' => is_array($prefix) ? (string) ($prefix['default'] ?? '') : (string) $prefix,
+ ];
+ foreach (self::ENVIRONMENT_VARIABLES as $variable) {
+ $value = getenv($variable);
+ if (is_string($value) && $value !== '') {
+ $values['env:' . $variable] = $value;
+ }
+ }
+ return $values;
+ }
+
+}
diff --git a/modules/quant_api/tests/modules/quant_api_oauth_test/quant_api_oauth_test.info.yml b/modules/quant_api/tests/modules/quant_api_oauth_test/quant_api_oauth_test.info.yml
new file mode 100644
index 00000000..c01ac885
--- /dev/null
+++ b/modules/quant_api/tests/modules/quant_api_oauth_test/quant_api_oauth_test.info.yml
@@ -0,0 +1,8 @@
+name: 'Quant API OAuth test'
+description: 'A fake Quant dashboard and content API for the Log in to Quant tests.'
+type: module
+package: Testing
+hidden: true
+core_version_requirement: ^11
+dependencies:
+ - quantcdn:quant_api
diff --git a/modules/quant_api/tests/modules/quant_api_oauth_test/quant_api_oauth_test.routing.yml b/modules/quant_api/tests/modules/quant_api_oauth_test/quant_api_oauth_test.routing.yml
new file mode 100644
index 00000000..42b38672
--- /dev/null
+++ b/modules/quant_api/tests/modules/quant_api_oauth_test/quant_api_oauth_test.routing.yml
@@ -0,0 +1,8 @@
+quant_api_oauth_test.authorize:
+ path: '/quant-test-dashboard/oauth/authorize'
+ defaults:
+ _controller: '\Drupal\quant_api_oauth_test\Controller\FakeDashboardController::authorize'
+ requirements:
+ _access: 'TRUE'
+ options:
+ no_cache: TRUE
diff --git a/modules/quant_api/tests/modules/quant_api_oauth_test/quant_api_oauth_test.services.yml b/modules/quant_api/tests/modules/quant_api_oauth_test/quant_api_oauth_test.services.yml
new file mode 100644
index 00000000..b4f93392
--- /dev/null
+++ b/modules/quant_api/tests/modules/quant_api_oauth_test/quant_api_oauth_test.services.yml
@@ -0,0 +1,6 @@
+services:
+ quant_api_oauth_test.middleware:
+ class: Drupal\quant_api_oauth_test\FakeQuantMiddleware
+ arguments: ['@state']
+ tags:
+ - { name: http_client_middleware }
diff --git a/modules/quant_api/tests/modules/quant_api_oauth_test/src/Controller/FakeDashboardController.php b/modules/quant_api/tests/modules/quant_api_oauth_test/src/Controller/FakeDashboardController.php
new file mode 100644
index 00000000..8ad4311c
--- /dev/null
+++ b/modules/quant_api/tests/modules/quant_api_oauth_test/src/Controller/FakeDashboardController.php
@@ -0,0 +1,29 @@
+set('quant_api_oauth_test.authorize', $request->query->all());
+ if (\Drupal::state()->get('quant_api_oauth_test.deny')) {
+ $target = $request->query->get('redirect_uri') . '?error=access_denied&state=' . rawurlencode((string) $request->query->get('state'));
+ }
+ else {
+ $state = (string) (\Drupal::state()->get('quant_api_oauth_test.forge_state') ?: $request->query->get('state'));
+ $target = $request->query->get('redirect_uri') . '?code=test-code&state=' . rawurlencode($state);
+ }
+ return new TrustedRedirectResponse($target);
+ }
+
+}
diff --git a/modules/quant_api/tests/modules/quant_api_oauth_test/src/FakeQuantMiddleware.php b/modules/quant_api/tests/modules/quant_api_oauth_test/src/FakeQuantMiddleware.php
new file mode 100644
index 00000000..26ccff44
--- /dev/null
+++ b/modules/quant_api/tests/modules/quant_api_oauth_test/src/FakeQuantMiddleware.php
@@ -0,0 +1,159 @@
+answer($request);
+ return $response ? Create::promiseFor($response) : $handler($request, $options);
+ };
+ };
+ }
+
+ /**
+ * The canned answer, or NULL to let the request through.
+ */
+ protected function answer(RequestInterface $request): ?Response {
+ $path = $request->getUri()->getPath();
+ if (in_array($request->getUri()->getHost(), ['api.quantcdn.io', 'api.quantgov.cloud'], TRUE) && $path === '/v1/ping') {
+ return $this->ping($request);
+ }
+ $at = strpos($path, '/quant-test-dashboard/');
+ if ($at === FALSE) {
+ return NULL;
+ }
+ $sub = substr($path, $at + strlen('/quant-test-dashboard'));
+ if ($sub === '/oauth/authorize') {
+ // The browser's approval page: served by the test site itself.
+ return NULL;
+ }
+ $this->record($request);
+ return $this->dashboard($request, $sub);
+ }
+
+ /**
+ * The dashboard API.
+ */
+ protected function dashboard(RequestInterface $request, string $path): Response {
+ $method = $request->getMethod();
+ return match (TRUE) {
+ $method === 'POST' && $path === '/oauth/token' => $this->token($request),
+ $method === 'GET' && $path === '/api/v2/organizations/test-org/projects' => $this->bearer($request, $this->limited() ? self::json(403, ['code' => 'PROJECT_LEVEL_ONLY']) : self::json(200, [
+ ['machine_name' => 'site-a', 'name' => 'Site A'],
+ ['machine_name' => 'site-b', 'name' => 'Site B'],
+ ])),
+ $method === 'POST' && $path === '/api/oauth/token/project' => $this->bearer($request, $this->limit($request)),
+ $method === 'GET' && preg_match('#^/api/v2/organizations/test-org/projects/([a-z-]+)$#', $path, $m) === 1 => $this->bearer($request, $this->project($m[1])),
+ default => self::json(404, ['message' => 'Not found']),
+ };
+ }
+
+ /**
+ * The token endpoint: checks the PKCE verifier against the challenge.
+ */
+ protected function token(RequestInterface $request): Response {
+ parse_str((string) $request->getBody(), $form);
+ $challenge = (string) ($this->state->get('quant_api_oauth_test.authorize')['code_challenge'] ?? '');
+ $expected = rtrim(strtr(base64_encode(hash('sha256', (string) ($form['code_verifier'] ?? ''), TRUE)), '+/', '-_'), '=');
+ if (($form['code'] ?? '') !== 'test-code' || $challenge === '' || !hash_equals($challenge, $expected) || ($form['client_id'] ?? '') !== 'drupal-quant') {
+ return self::json(400, ['error' => 'invalid_grant']);
+ }
+ $this->state->delete('quant_api_oauth_test.limited');
+ return self::json(200, [
+ 'access_token' => 'qca_fake_access',
+ 'refresh_token' => 'qcr_fake_refresh',
+ 'token_type' => 'Bearer',
+ 'expires_in' => 7776000,
+ 'scope' => 'projects:read',
+ 'user' => [
+ 'name' => 'Test Editor',
+ 'email' => 'editor@example.com',
+ 'organizations' => [['machine_name' => 'test-org', 'name' => 'Test Org']],
+ ],
+ ]);
+ }
+
+ /**
+ * The project limit: narrows once, refuses to widen.
+ */
+ protected function limit(RequestInterface $request): Response {
+ $body = json_decode((string) $request->getBody(), TRUE) ?: [];
+ $limited = $this->limited();
+ if ($limited !== NULL && $limited !== ($body['project'] ?? NULL)) {
+ return self::json(409, ['code' => 'TOKEN_ALREADY_LIMITED']);
+ }
+ $this->state->set('quant_api_oauth_test.limited', (string) ($body['project'] ?? ''));
+ return self::json(200, ['organization' => $body['organization'] ?? '', 'project' => $body['project'] ?? '']);
+ }
+
+ /**
+ * One project with its write token, for the limited project only.
+ */
+ protected function project(string $project): Response {
+ if ($this->limited() !== $project) {
+ return self::json(403, ['code' => 'PROJECT_LEVEL_ONLY']);
+ }
+ return self::json(200, ['machine_name' => $project, 'write_token' => self::WRITE_TOKEN_PREFIX . $project]);
+ }
+
+ /**
+ * The content API ping: 200 for the write token of the limited project.
+ */
+ protected function ping(RequestInterface $request): Response {
+ $ok = $request->getHeaderLine('Quant-Token') === self::WRITE_TOKEN_PREFIX . $request->getHeaderLine('Quant-Project');
+ return self::json($ok ? 200 : 401, $ok ? ['project' => $request->getHeaderLine('Quant-Project')] : ['error' => TRUE]);
+ }
+
+ /**
+ * Requires the bearer of the login.
+ */
+ protected function bearer(RequestInterface $request, Response $response): Response {
+ return $request->getHeaderLine('Authorization') === 'Bearer qca_fake_access' ? $response : self::json(401, ['message' => 'Invalid token']);
+ }
+
+ /**
+ * The project the login is limited to, if any.
+ */
+ protected function limited(): ?string {
+ return $this->state->get('quant_api_oauth_test.limited');
+ }
+
+ /**
+ * Records a request: method and path only.
+ */
+ protected function record(RequestInterface $request): void {
+ $log = $this->state->get('quant_api_oauth_test.requests', []);
+ $log[] = $request->getMethod() . ' ' . $request->getUri()->getPath();
+ $this->state->set('quant_api_oauth_test.requests', $log);
+ }
+
+ /**
+ * A JSON response.
+ */
+ protected static function json(int $status, array $body): Response {
+ return new Response($status, ['Content-Type' => 'application/json'], json_encode($body));
+ }
+
+}
diff --git a/modules/quant_api/tests/src/Functional/OAuthConnectTest.php b/modules/quant_api/tests/src/Functional/OAuthConnectTest.php
new file mode 100644
index 00000000..45e5af50
--- /dev/null
+++ b/modules/quant_api/tests/src/Functional/OAuthConnectTest.php
@@ -0,0 +1,196 @@
+writeSettings([
+ 'settings' => [
+ 'quant_api_dashboard_url' => (object) [
+ 'value' => $this->baseUrl . '/quant-test-dashboard',
+ 'required' => TRUE,
+ ],
+ ],
+ ]);
+ $this->drupalLogin($this->drupalCreateUser(['configure quant']));
+ }
+
+ /**
+ * Logs in on the fake dashboard and picks a project.
+ */
+ protected function logInAndPick(string $project): void {
+ $this->drupalGet('admin/config/development/quant/api');
+ $this->submitForm([], 'Log in to Quant');
+ $this->assertSession()->pageTextContains('Logged in as editor@example.com');
+ $this->submitForm(['project' => 'test-org:' . $project], 'Use this project');
+ }
+
+ /**
+ * The store, read fresh from the test site's database.
+ */
+ protected function store(): OAuthTokenStore {
+ return \Drupal::service('quant_api.oauth_store');
+ }
+
+ /**
+ * The login, the project choice, change project and log out.
+ */
+ public function testLoginChooseChangeLogout(): void {
+ $this->logInAndPick('site-b');
+
+ $authorize = \Drupal::state()->get('quant_api_oauth_test.authorize');
+ $this->assertSame('drupal-quant', $authorize['client_id']);
+ $this->assertSame('S256', $authorize['code_challenge_method']);
+ $this->assertSame('full_access', $authorize['scope']);
+ $this->assertStringEndsWith('/admin/config/development/quant/api/oauth/callback', $authorize['redirect_uri']);
+
+ $session = $this->assertSession();
+ $session->pageTextContains('This site now publishes to the Quant project site-b.');
+ $session->pageTextContains('Connected to the Quant project site-b.');
+ $session->pageTextContains('it can reach only that project');
+ $session->linkExists('Change project');
+
+ $config = $this->config('quant_api.settings');
+ $this->assertSame('oauth', $config->get('auth_method'));
+ $this->assertSame('test-org', $config->get('api_account'));
+ $this->assertSame('site-b', $config->get('api_project'));
+ $this->assertEmpty($config->get('api_token'));
+ $this->assertSame('fake-write-token-site-b', $this->store()->get(OAuthTokenStore::WRITE_TOKEN));
+
+ // The module limited the token before it read the write token.
+ $requests = \Drupal::state()->get('quant_api_oauth_test.requests');
+ $limit = array_search('POST /quant-test-dashboard/api/oauth/token/project', $requests, TRUE);
+ $fetch = array_search('GET /quant-test-dashboard/api/v2/organizations/test-org/projects/site-b', $requests, TRUE);
+ $this->assertNotFalse($limit);
+ $this->assertGreaterThan($limit, $fetch);
+
+ // Change project: a new login, the old project stays until the choice.
+ $this->clickLink('Change project');
+ $this->assertSame('site-b', $this->config('quant_api.settings')->get('api_project'));
+ $this->submitForm(['project' => 'test-org:site-a'], 'Use this project');
+ $session->pageTextContains('Connected to the Quant project site-a.');
+ $this->assertSame('fake-write-token-site-a', $this->store()->get(OAuthTokenStore::WRITE_TOKEN));
+
+ $this->clickLink('Log out of Quant');
+ $session->pageTextContains('This site is logged out of Quant.');
+ $this->assertSame('token', $this->config('quant_api.settings')->get('auth_method'));
+ $this->assertSame([], array_filter($this->store()->getAll()));
+ $session->buttonExists('Log in to Quant');
+ }
+
+ /**
+ * A callback with a state from another login is refused.
+ */
+ public function testForgedStateIsRefused(): void {
+ \Drupal::state()->set('quant_api_oauth_test.forge_state', 'attacker-state');
+ $this->drupalGet('admin/config/development/quant/api');
+ $this->submitForm([], 'Log in to Quant');
+ $this->assertSession()->pageTextContains('The login expired or was started in another browser.');
+ $this->assertNotContains('POST /quant-test-dashboard/oauth/token', \Drupal::state()->get('quant_api_oauth_test.requests', []));
+ $this->assertSame('token', $this->config('quant_api.settings')->get('auth_method'));
+ }
+
+ /**
+ * A login cancelled in the dashboard changes nothing.
+ */
+ public function testCancelledLogin(): void {
+ \Drupal::state()->set('quant_api_oauth_test.deny', TRUE);
+ $this->drupalGet('admin/config/development/quant/api');
+ $this->submitForm([], 'Log in to Quant');
+ $this->assertSession()->pageTextContains('The login was cancelled in the Quant dashboard.');
+ $this->assertNull($this->store()->get(OAuthTokenStore::ACCESS_TOKEN));
+ }
+
+ /**
+ * The connect route needs its CSRF token.
+ */
+ public function testConnectNeedsCsrfToken(): void {
+ $this->drupalGet('admin/config/development/quant/api/oauth/connect');
+ $this->assertSession()->statusCodeEquals(403);
+ $this->drupalGet('admin/config/development/quant/api/oauth/disconnect');
+ $this->assertSession()->statusCodeEquals(403);
+ }
+
+ /**
+ * The routes need the "configure quant" permission.
+ */
+ public function testRoutesNeedPermission(): void {
+ $this->drupalLogin($this->drupalCreateUser(['access content']));
+ $paths = [
+ 'admin/config/development/quant/api',
+ 'admin/config/development/quant/api/oauth/callback',
+ 'admin/config/development/quant/api/oauth/project',
+ ];
+ foreach ($paths as $path) {
+ $this->drupalGet($path);
+ $this->assertSession()->statusCodeEquals(403);
+ }
+ }
+
+ /**
+ * A multi-domain site sees why it uses token connect.
+ */
+ public function testMultiDomainSiteUsesTokenConnect(): void {
+ \Drupal::service('config.storage')->write('domain.config.example_com.quant_api.settings', ['api_project' => 'example']);
+ $this->drupalGet('admin/config/development/quant/api');
+ $this->assertSession()->pageTextContains('Log in to Quant supports one project per site for now');
+ $this->assertSession()->buttonExists('Log in to Quant');
+ $this->assertSession()->elementAttributeExists('css', 'input[value="Log in to Quant"]', 'disabled');
+ }
+
+ /**
+ * Token connect still works, and QuantGov sets the QuantGov endpoint.
+ */
+ public function testTokenConnectAndPlatform(): void {
+ $this->drupalGet('admin/config/development/quant/api');
+ $this->submitForm([
+ 'platform' => 'quantgov',
+ 'api_account' => 'test-org',
+ 'api_project' => 'site-c',
+ 'api_token' => 'typed-token',
+ ], 'Save configuration');
+ $config = $this->config('quant_api.settings');
+ $this->assertSame('quantgov', $config->get('platform'));
+ $this->assertSame('https://api.quantgov.cloud', $config->get('api_endpoint'));
+ $this->assertSame('token', $config->get('auth_method'));
+ $this->assertSame('typed-token', $config->get('api_token'));
+
+ // An empty token field keeps the saved token.
+ $this->submitForm(['api_project' => 'site-d'], 'Save configuration');
+ $this->assertSame('typed-token', $this->config('quant_api.settings')->get('api_token'));
+ $this->assertSame('site-d', $this->config('quant_api.settings')->get('api_project'));
+ }
+
+}
diff --git a/modules/quant_api/tests/src/Kernel/OAuthAuthServiceTest.php b/modules/quant_api/tests/src/Kernel/OAuthAuthServiceTest.php
new file mode 100644
index 00000000..412b54f6
--- /dev/null
+++ b/modules/quant_api/tests/src/Kernel/OAuthAuthServiceTest.php
@@ -0,0 +1,329 @@
+installConfig(['quant_api']);
+ $this->mock = new MockHandler();
+ $stack = HandlerStack::create($this->mock);
+ $stack->push(Middleware::history($this->history));
+ // Set before the OAuth services are first built, so they get this client.
+ $this->container->set('http_client', new Client(['handler' => $stack]));
+ }
+
+ /**
+ * The store.
+ */
+ protected function store(): OAuthTokenStore {
+ return $this->container->get('quant_api.oauth_store');
+ }
+
+ /**
+ * The service under test.
+ *
+ * @return \Drupal\quant_api\OAuth\AuthService
+ * The service.
+ */
+ protected function auth() {
+ return $this->container->get('quant_api.oauth');
+ }
+
+ /**
+ * Stores a connection whose access token expires in $expires_in seconds.
+ */
+ protected function connect(int $expires_in, array $extra = []): void {
+ $this->auth()->saveConnection([
+ 'access_token' => 'qca_old',
+ 'refresh_token' => 'qcr_old',
+ 'expires_in' => $expires_in,
+ ], 'write-token', ['name' => 'Editor', 'email' => 'editor@example.com']);
+ if ($extra) {
+ $this->store()->setMany($extra);
+ }
+ }
+
+ /**
+ * A JSON response.
+ */
+ protected static function json(int $status, array $body): Response {
+ return new Response($status, ['Content-Type' => 'application/json'], json_encode($body));
+ }
+
+ /**
+ * A successful refresh answer with a rotated pair.
+ */
+ protected static function rotated(): Response {
+ return self::json(200, ['access_token' => 'qca_new', 'refresh_token' => 'qcr_new', 'expires_in' => 7776000]);
+ }
+
+ /**
+ * The RFC 7636 example: S256 of the example verifier.
+ *
+ * @covers ::computePkceChallenge
+ * @covers ::generatePkceVerifier
+ */
+ public function testPkce(): void {
+ $this->assertSame('E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM', $this->auth()->computePkceChallenge('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'));
+ $verifier = $this->auth()->generatePkceVerifier();
+ $this->assertMatchesRegularExpression('/^[A-Za-z0-9_-]{43}$/', $verifier);
+ }
+
+ /**
+ * The authorize URL names the client, the scope and an S256 challenge.
+ *
+ * @covers ::getAuthorizationUrl
+ */
+ public function testAuthorizationUrlPerPlatform(): void {
+ $url = $this->auth()->getAuthorizationUrl('st', 'https://site.example/cb', 'ch');
+ $this->assertStringStartsWith('https://dashboard.quantcdn.io/oauth/authorize?', $url);
+ parse_str(parse_url($url, PHP_URL_QUERY), $query);
+ $this->assertSame([
+ 'client_id' => Platform::CLIENT_ID,
+ 'response_type' => 'code',
+ 'redirect_uri' => 'https://site.example/cb',
+ 'state' => 'st',
+ 'scope' => 'full_access',
+ 'code_challenge' => 'ch',
+ 'code_challenge_method' => 'S256',
+ ], $query);
+
+ $this->config('quant_api.settings')->set('platform', Platform::QUANTGOV)->save();
+ $this->assertStringStartsWith('https://dash.quantgov.cloud/oauth/authorize?', $this->auth()->getAuthorizationUrl('st', 'https://site.example/cb', 'ch'));
+ }
+
+ /**
+ * A token far from expiry is used as it is, with no call.
+ *
+ * @covers ::getValidAccessToken
+ */
+ public function testFreshTokenNeedsNoRefresh(): void {
+ $this->connect(86400 * 60);
+ $this->assertSame('qca_old', $this->auth()->getValidAccessToken());
+ $this->assertCount(0, $this->history);
+ }
+
+ /**
+ * An expiring token is refreshed and the rotated pair is saved.
+ *
+ * @covers ::getValidAccessToken
+ */
+ public function testExpiringTokenIsRefreshedAndRotated(): void {
+ $this->connect(30);
+ $this->mock->append(self::rotated());
+
+ $this->assertSame('qca_new', $this->auth()->getValidAccessToken());
+ $this->assertSame('qcr_new', $this->store()->get(OAuthTokenStore::REFRESH_TOKEN));
+ $this->assertNull($this->store()->get(OAuthTokenStore::PENDING));
+ $this->assertSame('write-token', $this->store()->get(OAuthTokenStore::WRITE_TOKEN));
+ parse_str((string) $this->history[0]['request']->getBody(), $sent);
+ $this->assertSame([
+ 'grant_type' => 'refresh_token',
+ 'refresh_token' => 'qcr_old',
+ 'client_id' => 'drupal-quant',
+ ], $sent);
+ }
+
+ /**
+ * A final refusal stops further refresh attempts and keeps the tokens.
+ *
+ * @covers ::getValidAccessToken
+ * @covers ::getReconnectReason
+ */
+ public function testRefusalIsFinal(): void {
+ $this->connect(30);
+ $this->mock->append(self::json(400, [
+ 'error' => 'invalid_grant',
+ 'error_description' => 'Refresh token revoked',
+ ]));
+
+ $this->assertSame('qca_old', $this->auth()->getValidAccessToken());
+ $this->assertSame('refused', $this->auth()->getReconnectReason());
+ $this->assertSame('qca_old', $this->auth()->getValidAccessToken());
+ $this->assertCount(1, $this->history, 'No second refresh after a refusal.');
+ $this->assertSame('write-token', $this->store()->get(OAuthTokenStore::WRITE_TOKEN));
+ }
+
+ /**
+ * A peer's "just rotated" answer backs off and keeps the stored pair.
+ *
+ * @covers ::getValidAccessToken
+ */
+ public function testJustRotatedBacksOff(): void {
+ $this->connect(30);
+ $this->mock->append(self::json(400, [
+ 'error' => 'invalid_grant',
+ 'error_description' => 'Refresh token was just rotated',
+ ]));
+
+ $this->auth()->getValidAccessToken();
+ $this->assertNull($this->auth()->getReconnectReason());
+ $this->assertGreaterThan(time(), $this->store()->get(OAuthTokenStore::RETRY_AFTER));
+ $this->auth()->getValidAccessToken();
+ $this->assertCount(1, $this->history, 'The back-off holds the next request.');
+ }
+
+ /**
+ * No answer leaves a pending marker; the same token is retried in the window.
+ *
+ * @covers ::getValidAccessToken
+ */
+ public function testLostAnswerIsRetriedInsideThePortalWindow(): void {
+ $this->connect(30);
+ $this->mock->append(new ConnectException('timeout', new Request('POST', 'https://dashboard.quantcdn.io/oauth/token')));
+ $this->auth()->getValidAccessToken();
+ $pending = $this->store()->get(OAuthTokenStore::PENDING);
+ $this->assertSame(hash('sha256', 'qcr_old'), $pending['token']);
+
+ // Too early: the portal answers "just rotated" before 45 s.
+ $this->auth()->getValidAccessToken();
+ $this->assertCount(1, $this->history);
+
+ // Inside the recovery window: the same refresh token is sent again.
+ $pending['since'] = time() - 60;
+ $this->store()->set(OAuthTokenStore::PENDING, $pending);
+ $this->mock->append(self::rotated());
+ $this->assertSame('qca_new', $this->auth()->getValidAccessToken());
+ parse_str((string) $this->history[1]['request']->getBody(), $sent);
+ $this->assertSame('qcr_old', $sent['refresh_token']);
+ }
+
+ /**
+ * A database copy does not refresh the original site's tokens.
+ *
+ * @covers ::getValidAccessToken
+ * @covers ::getReconnectReason
+ */
+ public function testCopiedDatabaseDoesNotRefresh(): void {
+ $this->connect(30);
+ $fingerprint = $this->store()->get(OAuthTokenStore::FINGERPRINT);
+ $fingerprint['uuid'] = hash('sha256', 'another site');
+ $this->store()->set(OAuthTokenStore::FINGERPRINT, $fingerprint);
+
+ $this->assertSame('qca_old', $this->auth()->getValidAccessToken());
+ $this->assertSame('other_site', $this->auth()->getReconnectReason());
+ $this->assertCount(0, $this->history);
+ }
+
+ /**
+ * Cron refreshes 30 days before expiry, a request only at the last minute.
+ *
+ * @covers ::refreshFromCron
+ */
+ public function testCronRefreshesEarly(): void {
+ $this->connect(86400 * 20, [OAuthTokenStore::LIFETIME => 7776000]);
+ $this->auth()->getValidAccessToken();
+ $this->assertCount(0, $this->history);
+
+ $this->mock->append(self::rotated());
+ $this->auth()->refreshFromCron();
+ $this->assertSame('qca_new', $this->store()->get(OAuthTokenStore::ACCESS_TOKEN));
+ }
+
+ /**
+ * Disconnect deletes every stored value.
+ *
+ * @covers ::disconnect
+ */
+ public function testDisconnect(): void {
+ $this->connect(86400);
+ $this->auth()->disconnect();
+ $this->assertSame([], array_filter($this->store()->getAll()));
+ }
+
+ /**
+ * The write token: settings.php, then the OAuth connection, then config.
+ *
+ * @covers \Drupal\quant_api\Credentials::getToken
+ */
+ public function testCredentialOrder(): void {
+ $credentials = $this->container->get('quant_api.credentials');
+ $this->config('quant_api.settings')->set('api_token', 'typed-token')->save();
+ $this->assertSame('typed-token', $credentials->getToken());
+
+ $this->connect(86400);
+ $this->assertSame('typed-token', $credentials->getToken(), 'The OAuth store is used only with auth_method oauth.');
+ $this->config('quant_api.settings')->set('auth_method', 'oauth')->save();
+ $this->assertSame('write-token', $credentials->getToken());
+
+ $GLOBALS['config']['quant_api.settings']['api_token'] = 'settings-php-token';
+ $this->container->get('config.factory')->reset('quant_api.settings');
+ $this->assertSame('settings-php-token', $credentials->getToken());
+ unset($GLOBALS['config']['quant_api.settings']);
+ }
+
+ /**
+ * No token ever reaches exportable configuration.
+ *
+ * @covers ::saveConnection
+ */
+ public function testTokensStayOutOfConfig(): void {
+ $this->connect(86400);
+ $this->config('quant_api.settings')->set('auth_method', 'oauth')->save();
+ $raw = json_encode($this->container->get('config.storage')->read('quant_api.settings'));
+ foreach (['qca_old', 'qcr_old', 'write-token'] as $secret) {
+ $this->assertStringNotContainsString($secret, $raw);
+ }
+ }
+
+ /**
+ * Sites from before 2.3 get the platform of their endpoint.
+ *
+ * @covers ::getPlatform
+ */
+ public function testUpdateHookSetsThePlatformFromTheEndpoint(): void {
+ $this->config('quant_api.settings')
+ ->clear('platform')
+ ->clear('auth_method')
+ ->set('api_endpoint', 'https://api.quantgov.cloud')
+ ->save();
+ \Drupal::moduleHandler()->loadInclude('quant_api', 'install');
+ quant_api_update_11001();
+
+ $config = $this->config('quant_api.settings');
+ $this->assertSame('quantgov', $config->get('platform'));
+ $this->assertSame('token', $config->get('auth_method'));
+ $this->assertSame('https://dash.quantgov.cloud', $this->auth()->getDashboardUrl());
+ }
+
+}
From 7c2edb22602e45e7095572e550d2c2ea994c7a0d Mon Sep 17 00:00:00 2001
From: Stuart Rowlands
Date: Sun, 8 Nov 2026 15:08:33 +1000
Subject: [PATCH 07/30] sec: the Quant API client sends nothing when no project
token is set
An unconnected site sent requests with empty credentials, for example a
PATCH /v1/unpublish when a search page was deleted. Every call now fails
closed before any request, with one log line that says why.
---
modules/quant_api/src/Client/QuantClient.php | 42 +++++++++++++++++++
.../tests/src/Unit/QuantClientTest.php | 31 ++++++++++++++
2 files changed, 73 insertions(+)
diff --git a/modules/quant_api/src/Client/QuantClient.php b/modules/quant_api/src/Client/QuantClient.php
index 97311b04..353b3e91 100644
--- a/modules/quant_api/src/Client/QuantClient.php
+++ b/modules/quant_api/src/Client/QuantClient.php
@@ -124,6 +124,33 @@ protected function refreshCredentials() : void {
$this->tlsDisabled = $this->config->get('api_tls_disabled');
}
+ /**
+ * Refuses any call when no project token is set: no request leaves Drupal.
+ *
+ * An unconnected site used to send requests with empty credentials (for
+ * example an unpublish when a search page was deleted). Fail closed and say
+ * why, once per process.
+ *
+ * @param string $operation
+ * What is being attempted, for the log.
+ *
+ * @return bool
+ * TRUE when the caller must not proceed.
+ */
+ protected function refusesWithoutToken(string $operation) : bool {
+ if (!empty($this->token)) {
+ return FALSE;
+ }
+ static $logged = FALSE;
+ if (!$logged) {
+ $logged = TRUE;
+ $this->logger->warning('Quant API: no project token is set, so the module did not call the API (@operation). Connect the site on the Quant API settings page.', [
+ '@operation' => $operation,
+ ]);
+ }
+ return TRUE;
+ }
+
/**
* Refuses a write the current context cannot be trusted to address.
*
@@ -139,6 +166,9 @@ protected function refreshCredentials() : void {
* TRUE when the caller must not proceed.
*/
protected function refusesWrite(string $operation) : bool {
+ if ($this->refusesWithoutToken($operation)) {
+ return TRUE;
+ }
if (!PublishGuard::refuses($host)) {
return FALSE;
}
@@ -190,6 +220,9 @@ public function getOverrides() {
*/
public function ping() {
$this->refreshCredentials();
+ if ($this->refusesWithoutToken('to check the connection')) {
+ return FALSE;
+ }
try {
// @todo Switch from 'Quant-Customer' to 'Quant-Organization'.
@@ -231,6 +264,9 @@ public function ping() {
*/
public function project() {
$this->refreshCredentials();
+ if ($this->refusesWithoutToken('to read the project')) {
+ return FALSE;
+ }
try {
// @todo Switch from 'Quant-Customer' to 'Quant-Organization'.
@@ -271,6 +307,9 @@ public function project() {
*/
public function search() {
$this->refreshCredentials();
+ if ($this->refusesWithoutToken('to read the search index')) {
+ return FALSE;
+ }
try {
// @todo Switch from 'Quant-Customer' to 'Quant-Organization'.
@@ -442,6 +481,9 @@ public function unpublish(string $url) : array {
*/
public function getUrlMeta(array $urls) : array {
$this->refreshCredentials();
+ if ($this->refusesWithoutToken('to read page metadata')) {
+ return [];
+ }
// Format array if it's not already.
if (!array_key_exists('Quant-Url', $urls)) {
$urls = [
diff --git a/modules/quant_api/tests/src/Unit/QuantClientTest.php b/modules/quant_api/tests/src/Unit/QuantClientTest.php
index c67a2a36..cc8af5b2 100644
--- a/modules/quant_api/tests/src/Unit/QuantClientTest.php
+++ b/modules/quant_api/tests/src/Unit/QuantClientTest.php
@@ -6,6 +6,7 @@
use Drupal\Core\Config\ImmutableConfig;
use Drupal\Core\DependencyInjection\ContainerBuilder;
use Drupal\Core\Logger\LoggerChannelFactoryInterface;
+use Drupal\Core\Logger\LoggerChannelInterface;
use Drupal\Core\Messenger\MessengerInterface;
use Drupal\quant_api\Client\QuantClient;
use Drupal\quant_api\Exception\InvalidPayload;
@@ -491,4 +492,34 @@ public function testGetOverridesReportsChangedKeys() {
$this->assertEquals(['api_project' => 'overridden-project'], $client->getOverrides());
}
+ /**
+ * With no token the client sends nothing at all (fail closed).
+ *
+ * An unconnected site sent an unpublish with empty credentials when a
+ * search page was deleted. No call may leave Drupal without a token.
+ */
+ public function testNoTokenMeansNoRequest() : void {
+ $logger = $this->createMock(LoggerChannelInterface::class);
+ $logger->expects($this->atMost(1))->method('warning')
+ ->with($this->stringContains('no project token is set'));
+ $factory = $this->createMock(LoggerChannelFactoryInterface::class);
+ $factory->method('get')->willReturn($logger);
+ $stack = HandlerStack::create(new MockHandler([]));
+ $stack->push(Middleware::history($this->history));
+ $client = new QuantClient(new Client(['handler' => $stack]), $this->configFactory(['api_token' => '']), $factory);
+
+ $this->assertSame([], $client->send(['url' => '/x', 'content' => 'x']));
+ $this->assertSame([], $client->sendRedirect(['url' => '/a', 'redirect_url' => '/b']));
+ $this->assertSame([], $client->unpublish('/x'));
+ $this->assertSame([], $client->sendSearchRecords([['url' => '/x']]));
+ $this->assertSame([], $client->clearSearchIndex());
+ $this->assertSame([], $client->addFacets(['tags_en']));
+ $this->assertSame([], $client->getUrlMeta(['/x']));
+ $this->assertFalse($client->ping());
+ $this->assertFalse($client->project());
+ $this->assertFalse($client->search());
+
+ $this->assertCount(0, $this->history, 'No request was sent.');
+ }
+
}
From 7162bff7d5399576fcbe7f0796c5c7fbc90effad Mon Sep 17 00:00:00 2001
From: Stuart Rowlands
Date: Sun, 8 Nov 2026 15:48:04 +1000
Subject: [PATCH 08/30] fix: a copied site publishes nothing, the login keeps
its platform and endpoint, and a rotated write token recovers
- A copy of the database (fingerprint mismatch) gets no write token in
login mode, and the settings page says to log in on the copy.
- The platform chosen for a login is saved only when the login completes,
so a cancelled login never re-points a working token connection.
- In login mode the endpoint is the platform's own host; a custom endpoint
(a settings.php override aside) no longer receives the hidden token.
- Cron re-reads the project write token with the login after a refresh and
at least daily, so a token rotated in the dashboard recovers.
---
modules/quant_api/quant_api.module | 4 +-
modules/quant_api/quant_api.services.yml | 11 +++
modules/quant_api/src/Client/QuantClient.php | 2 +-
.../src/Controller/OAuthController.php | 12 +++
modules/quant_api/src/Credentials.php | 32 ++++++++
.../quant_api/src/Form/ProjectSelectForm.php | 6 ++
modules/quant_api/src/Form/SettingsForm.php | 24 ++++--
modules/quant_api/src/OAuth/AuthService.php | 30 +++++--
.../quant_api/src/OAuth/OAuthTokenStore.php | 3 +
.../quant_api/src/OAuth/WriteTokenSync.php | 80 +++++++++++++++++++
.../tests/src/Functional/OAuthConnectTest.php | 29 +++++++
.../tests/src/Kernel/OAuthAuthServiceTest.php | 75 +++++++++++++++++
12 files changed, 296 insertions(+), 12 deletions(-)
create mode 100644 modules/quant_api/src/OAuth/WriteTokenSync.php
diff --git a/modules/quant_api/quant_api.module b/modules/quant_api/quant_api.module
index 51c8e9dd..e1d07938 100644
--- a/modules/quant_api/quant_api.module
+++ b/modules/quant_api/quant_api.module
@@ -12,6 +12,8 @@ function quant_api_cron() {
// Refresh the "Log in to Quant" token well before it expires. Publishing
// does not depend on it: the write token does not expire.
if (\Drupal::service('quant_api.credentials')->usesOauth()) {
- \Drupal::service('quant_api.oauth')->refreshFromCron();
+ $refreshed = \Drupal::service('quant_api.oauth')->refreshFromCron();
+ // A write token rotated in the dashboard recovers without a new login.
+ \Drupal::service('quant_api.write_token_sync')->syncIfDue($refreshed);
}
}
diff --git a/modules/quant_api/quant_api.services.yml b/modules/quant_api/quant_api.services.yml
index 23bd3cf0..4f93fa59 100644
--- a/modules/quant_api/quant_api.services.yml
+++ b/modules/quant_api/quant_api.services.yml
@@ -13,6 +13,7 @@ services:
- '@config.factory'
- '@quant_api.oauth_store'
- '@config.storage'
+ - '@quant_api.site_fingerprint'
quant_api.oauth_store:
class: Drupal\quant_api\OAuth\OAuthTokenStore
@@ -51,3 +52,13 @@ services:
- '@event_dispatcher'
tags:
- { name: 'event_subscriber' }
+
+ quant_api.write_token_sync:
+ class: Drupal\quant_api\OAuth\WriteTokenSync
+ arguments:
+ - '@quant_api.oauth'
+ - '@quant_api.portal'
+ - '@quant_api.oauth_store'
+ - '@quant_api.credentials'
+ - '@config.factory'
+ - '@logger.factory'
diff --git a/modules/quant_api/src/Client/QuantClient.php b/modules/quant_api/src/Client/QuantClient.php
index 353b3e91..d5059f6f 100644
--- a/modules/quant_api/src/Client/QuantClient.php
+++ b/modules/quant_api/src/Client/QuantClient.php
@@ -120,7 +120,7 @@ protected function refreshCredentials() : void {
// An OAuth connection keeps its token outside config.
$this->token = $this->credentials ? $this->credentials->getToken($this->config) : $this->config->get('api_token');
$this->project = $this->config->get('api_project');
- $this->endpoint = $this->config->get('api_endpoint') . '/v1';
+ $this->endpoint = ($this->credentials ? $this->credentials->getEndpoint($this->config) : $this->config->get('api_endpoint')) . '/v1';
$this->tlsDisabled = $this->config->get('api_tls_disabled');
}
diff --git a/modules/quant_api/src/Controller/OAuthController.php b/modules/quant_api/src/Controller/OAuthController.php
index 794ce30d..25612e7f 100644
--- a/modules/quant_api/src/Controller/OAuthController.php
+++ b/modules/quant_api/src/Controller/OAuthController.php
@@ -24,6 +24,11 @@ class OAuthController extends ControllerBase {
*/
public const SESSION_KEY = 'quant_api_oauth';
+ /**
+ * The session key of the platform chosen before the login starts.
+ */
+ public const PLATFORM_KEY = 'quant_api_oauth_platform';
+
/**
* How long the dashboard approval may take, in seconds.
*/
@@ -63,11 +68,16 @@ public function connect(Request $request) {
$state = bin2hex(random_bytes(16));
$verifier = $this->auth->generatePkceVerifier();
$redirect_uri = self::callbackUrl();
+ // The platform chosen on the settings form, saved only when the login
+ // completes; "Change project" keeps the current one.
+ $platform = (string) $request->getSession()->remove(self::PLATFORM_KEY);
+ $this->auth->usePlatform($platform ?: NULL);
$request->getSession()->set(self::SESSION_KEY, [
'state' => $state,
'verifier' => $verifier,
'uid' => (int) $this->currentUser()->id(),
'created' => time(),
+ 'platform' => $this->auth->getPlatform(),
]);
$url = $this->auth->getAuthorizationUrl($state, $redirect_uri, $this->auth->computePkceChallenge($verifier));
$response = new TrustedRedirectResponse($url);
@@ -90,6 +100,7 @@ public function callback(Request $request) {
return $this->redirectToSettings();
}
+ $this->auth->usePlatform($login['platform'] ?? NULL);
$tokens = $this->auth->exchangeCode((string) $request->query->get('code'), self::callbackUrl(), $login['verifier']);
if ($tokens === NULL) {
$this->messenger()->addError($this->t('Quant did not accept the login. Try again. If it fails again, connect with a project token.'));
@@ -102,6 +113,7 @@ public function callback(Request $request) {
'tokens' => $tokens,
'user' => ['name' => (string) ($user['name'] ?? ''), 'email' => (string) ($user['email'] ?? '')],
'organizations' => self::organizations($user['organizations'] ?? []),
+ 'platform' => $this->auth->getPlatform(),
]);
return new RedirectResponse(Url::fromRoute('quant_api.oauth_project')->toString());
}
diff --git a/modules/quant_api/src/Credentials.php b/modules/quant_api/src/Credentials.php
index 7dfca264..a507802a 100644
--- a/modules/quant_api/src/Credentials.php
+++ b/modules/quant_api/src/Credentials.php
@@ -6,6 +6,8 @@
use Drupal\Core\Config\Config;
use Drupal\Core\Config\StorageInterface;
use Drupal\quant_api\OAuth\OAuthTokenStore;
+use Drupal\quant_api\OAuth\Platform;
+use Drupal\quant_api\OAuth\SiteFingerprint;
/**
* Resolves the project write token that the content API calls send.
@@ -28,8 +30,35 @@ public function __construct(
protected ConfigFactoryInterface $configFactory,
protected OAuthTokenStore $tokenStore,
protected StorageInterface $configStorage,
+ protected SiteFingerprint $fingerprint,
) {}
+ /**
+ * Whether the stored login was made on another site (a copied database).
+ *
+ * A copy must not publish with the original site's project token: staging
+ * edits would reach the production project.
+ */
+ public function isCopiedSite(): bool {
+ $stored = $this->tokenStore->get(OAuthTokenStore::FINGERPRINT);
+ return is_array($stored) && $this->fingerprint->differences($stored) !== [];
+ }
+
+ /**
+ * The content API endpoint, without /v1.
+ *
+ * With "Log in to Quant" the endpoint is the chosen platform's official
+ * host, so the hidden write token never goes elsewhere. A settings.php
+ * override still wins.
+ */
+ public function getEndpoint(?Config $config = NULL): string {
+ $config = $config ?? $this->configFactory->get('quant_api.settings');
+ if ($this->usesOauth($config) && !$config->hasOverrides('api_endpoint')) {
+ return Platform::apiEndpoint((string) $config->get('platform'));
+ }
+ return (string) $config->get('api_endpoint');
+ }
+
/**
* Whether a domain overrides the Quant API settings (a multi-domain site).
*
@@ -54,6 +83,9 @@ public function getToken(?Config $config = NULL): ?string {
return ((string) $config->get('api_token')) ?: NULL;
}
if ($this->usesOauth($config)) {
+ if ($this->isCopiedSite()) {
+ return NULL;
+ }
return ((string) $this->tokenStore->get(OAuthTokenStore::WRITE_TOKEN)) ?: NULL;
}
return ((string) $config->get('api_token')) ?: NULL;
diff --git a/modules/quant_api/src/Form/ProjectSelectForm.php b/modules/quant_api/src/Form/ProjectSelectForm.php
index f93b9d96..9768d5eb 100644
--- a/modules/quant_api/src/Form/ProjectSelectForm.php
+++ b/modules/quant_api/src/Form/ProjectSelectForm.php
@@ -8,6 +8,7 @@
use Drupal\quant_api\Credentials;
use Drupal\quant_api\OAuth\AuthService;
use Drupal\quant_api\OAuth\OAuthTokenStore;
+use Drupal\quant_api\OAuth\Platform;
use Drupal\quant_api\OAuth\PortalClient;
use Symfony\Component\DependencyInjection\ContainerInterface;
@@ -52,6 +53,7 @@ public function buildForm(array $form, FormStateInterface $form_state) {
if ($login === NULL) {
return $this->expired($form);
}
+ $this->auth->usePlatform($login['platform'] ?? NULL);
$options = $this->projectOptions($login);
if ($options === []) {
$form['empty'] = [
@@ -99,6 +101,8 @@ public function validateForm(array &$form, FormStateInterface $form_state) {
public function submitForm(array &$form, FormStateInterface $form_state) {
$uid = (int) $this->currentUser()->id();
$login = $this->tokenStore->getPendingLogin($uid);
+ $this->auth->usePlatform($login['platform'] ?? NULL);
+ $platform = $this->auth->getPlatform();
[$org, $project] = explode(':', (string) $form_state->getValue('project'), 2);
$access = (string) $login['tokens']['access_token'];
@@ -116,6 +120,8 @@ public function submitForm(array &$form, FormStateInterface $form_state) {
$this->auth->saveConnection($login['tokens'], $write_token, $login['user']);
$this->configFactory()->getEditable('quant_api.settings')
->set('auth_method', Credentials::METHOD_OAUTH)
+ ->set('platform', $platform)
+ ->set('api_endpoint', Platform::apiEndpoint($platform))
->set('api_account', $org)
->set('api_project', $project)
->set('api_token', NULL)
diff --git a/modules/quant_api/src/Form/SettingsForm.php b/modules/quant_api/src/Form/SettingsForm.php
index b61a7620..5c35c174 100644
--- a/modules/quant_api/src/Form/SettingsForm.php
+++ b/modules/quant_api/src/Form/SettingsForm.php
@@ -5,6 +5,7 @@
use Drupal\Core\Form\ConfigFormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Url;
+use Drupal\quant_api\Controller\OAuthController;
use Drupal\quant_api\Credentials;
use Drupal\quant_api\EndpointValidator;
use Drupal\quant_api\OAuth\AuthService;
@@ -177,7 +178,7 @@ protected function buildConnected($config): array {
*/
protected function reconnectMessage(string $reason) {
return match ($reason) {
- AuthService::RECONNECT_OTHER_SITE => $this->t('The stored login belongs to another copy of this site (for example a database copied from production). This copy does not refresh it. Log in to Quant again on this copy.'),
+ AuthService::RECONNECT_OTHER_SITE => $this->t('The stored login belongs to another copy of this site (for example a database copied from production). This copy does not publish or refresh with it. Log in to Quant on this copy.'),
AuthService::RECONNECT_REFUSED => $this->t('Quant refused to refresh the login. Content still publishes. Log in to Quant again to keep the connection current.'),
default => $this->t('The login expired. Content still publishes. Log in to Quant again to keep the connection current.'),
};
@@ -224,6 +225,7 @@ protected function buildTokenConnect($config): array {
* The advanced section: a custom endpoint and TLS.
*/
protected function buildAdvanced($config): array {
+ $oauth = $this->credentials->usesOauth($config);
return [
'#type' => 'details',
'#title' => $this->t('Advanced'),
@@ -231,8 +233,11 @@ protected function buildAdvanced($config): array {
'#type' => 'textfield',
'#title' => $this->t('API Endpoint'),
'#description' => $this->t('Leave the platform default unless Quant support gives you another endpoint. Use https and do not add /v1; the module adds it.'),
- '#default_value' => $config->get('api_endpoint') ?: Platform::apiEndpoint(Platform::QUANTCDN),
+ '#default_value' => $this->credentials->getEndpoint($config) ?: Platform::apiEndpoint(Platform::QUANTCDN),
'#parents' => ['api_endpoint'],
+ // A login publishes only to the platform's own host.
+ '#disabled' => $oauth,
+ '#field_suffix' => $oauth ? $this->t('Fixed to the platform while this site is logged in to Quant.') : NULL,
],
'api_tls_disabled' => [
'#type' => 'checkbox',
@@ -248,6 +253,10 @@ protected function buildAdvanced($config): array {
* Shows the connection status as a message.
*/
protected function showStatus(): void {
+ if ($this->credentials->usesOauth() && $this->credentials->isCopiedSite()) {
+ $this->messenger()->addError($this->t('This database was copied from another site, so this copy does not publish with the Quant login of that site. Log in to Quant on this copy, or connect it with a project token.'));
+ return;
+ }
if (!$this->credentials->hasToken()) {
return;
}
@@ -345,7 +354,10 @@ protected function resolveEndpoint(FormStateInterface $form_state): string {
* Saves the platform and starts the login.
*/
public function submitLogin(array &$form, FormStateInterface $form_state) {
- $this->savePlatform($form_state)->save();
+ // Saved only when the login completes (ProjectSelectForm), so a cancelled
+ // login never re-points a working connection.
+ $platform = (string) $form_state->getValue('platform');
+ $this->getRequest()->getSession()->set(OAuthController::PLATFORM_KEY, Platform::isKnown($platform) ? $platform : Platform::QUANTCDN);
// A redirect is not rendered, so the CSRF placeholder of a generated URL
// is never replaced. Add the real token for the route's path.
$url = Url::fromRoute('quant_api.oauth_connect');
@@ -401,9 +413,11 @@ protected function logOutOnPlatformChange(FormStateInterface $form_state): void
*/
protected function savePlatform(FormStateInterface $form_state) {
$platform = (string) $form_state->getValue('platform');
+ $platform = Platform::isKnown($platform) ? $platform : Platform::QUANTCDN;
+ $oauth = $this->credentials->usesOauth($this->config(self::SETTINGS));
return $this->configFactory()->getEditable(self::SETTINGS)
- ->set('platform', Platform::isKnown($platform) ? $platform : Platform::QUANTCDN)
- ->set('api_endpoint', $this->resolveEndpoint($form_state));
+ ->set('platform', $platform)
+ ->set('api_endpoint', $oauth ? Platform::apiEndpoint($platform) : $this->resolveEndpoint($form_state));
}
}
diff --git a/modules/quant_api/src/OAuth/AuthService.php b/modules/quant_api/src/OAuth/AuthService.php
index 7c158e0b..b84c2396 100644
--- a/modules/quant_api/src/OAuth/AuthService.php
+++ b/modules/quant_api/src/OAuth/AuthService.php
@@ -71,6 +71,11 @@ class AuthService {
*/
protected LoggerInterface $logger;
+ /**
+ * The platform of a login in progress, before it is saved.
+ */
+ protected ?string $platformOverride = NULL;
+
public function __construct(
protected ClientInterface $httpClient,
protected ConfigFactoryInterface $configFactory,
@@ -87,10 +92,20 @@ public function __construct(
* The platform the site is configured for.
*/
public function getPlatform(): string {
- $platform = (string) $this->configFactory->get('quant_api.settings')->get('platform');
+ $platform = $this->platformOverride ?? (string) $this->configFactory->get('quant_api.settings')->get('platform');
return Platform::isKnown($platform) ? $platform : Platform::QUANTCDN;
}
+ /**
+ * Uses a platform for this request only: a login in progress.
+ *
+ * The site's platform and endpoint change only when the login completes,
+ * so a cancelled login never re-points a working connection.
+ */
+ public function usePlatform(?string $platform): void {
+ $this->platformOverride = Platform::isKnown($platform) ? $platform : NULL;
+ }
+
/**
* The dashboard (OAuth server) of the configured platform.
*/
@@ -213,16 +228,21 @@ public function getValidAccessToken(): ?string {
/**
* Refreshes from cron 30 days before expiry, or at half a short lifetime.
+ *
+ * @return bool
+ * TRUE when the tokens were rotated.
*/
- public function refreshFromCron(): void {
+ public function refreshFromCron(): bool {
$record = $this->tokenStore->getAll();
if (empty($record[OAuthTokenStore::REFRESH_TOKEN])) {
- return;
+ return FALSE;
}
$margin = $this->getCronMargin($record);
- if ($this->getRefreshDecision($record, $margin, TRUE) === 'refresh') {
- $this->refreshIfAllowed($record, $margin);
+ if ($this->getRefreshDecision($record, $margin, TRUE) !== 'refresh') {
+ return FALSE;
}
+ $this->refreshIfAllowed($record, $margin);
+ return $this->tokenStore->get(OAuthTokenStore::ACCESS_TOKEN) !== $record[OAuthTokenStore::ACCESS_TOKEN];
}
/**
diff --git a/modules/quant_api/src/OAuth/OAuthTokenStore.php b/modules/quant_api/src/OAuth/OAuthTokenStore.php
index eb9e1858..62f9afc4 100644
--- a/modules/quant_api/src/OAuth/OAuthTokenStore.php
+++ b/modules/quant_api/src/OAuth/OAuthTokenStore.php
@@ -52,6 +52,8 @@ class OAuthTokenStore {
public const PENDING = 'pending';
+ public const SYNCED_AT = 'synced_at';
+
protected const NAMES = [
self::ACCESS_TOKEN,
self::REFRESH_TOKEN,
@@ -64,6 +66,7 @@ class OAuthTokenStore {
self::RETRY_AFTER,
self::CRON_RETRY_AFTER,
self::PENDING,
+ self::SYNCED_AT,
];
/**
diff --git a/modules/quant_api/src/OAuth/WriteTokenSync.php b/modules/quant_api/src/OAuth/WriteTokenSync.php
new file mode 100644
index 00000000..52e471c0
--- /dev/null
+++ b/modules/quant_api/src/OAuth/WriteTokenSync.php
@@ -0,0 +1,80 @@
+logger = $logger_factory->get('quant_api');
+ }
+
+ /**
+ * Syncs after a refresh, or when the last sync is a day old.
+ */
+ public function syncIfDue(bool $refreshed): ?bool {
+ $last = (int) $this->tokenStore->get(OAuthTokenStore::SYNCED_AT);
+ if (!$refreshed && $last > time() - self::INTERVAL) {
+ return NULL;
+ }
+ return $this->sync();
+ }
+
+ /**
+ * Re-reads the write token.
+ *
+ * @return bool|null
+ * TRUE when the token changed, FALSE when it did not, NULL when the sync
+ * could not run (no login, a copied site, or no answer).
+ */
+ public function sync(): ?bool {
+ $config = $this->configFactory->get('quant_api.settings');
+ if (!$this->credentials->usesOauth($config) || $this->credentials->isCopiedSite()) {
+ return NULL;
+ }
+ $access = $this->auth->getValidAccessToken();
+ if ($access === NULL) {
+ return NULL;
+ }
+ $this->tokenStore->set(OAuthTokenStore::SYNCED_AT, time());
+ $token = $this->portal->fetchWriteToken($access, (string) $config->get('api_account'), (string) $config->get('api_project'));
+ if ($token === NULL) {
+ return NULL;
+ }
+ if (hash_equals((string) $this->tokenStore->get(OAuthTokenStore::WRITE_TOKEN), $token)) {
+ return FALSE;
+ }
+ $this->tokenStore->set(OAuthTokenStore::WRITE_TOKEN, $token);
+ $this->logger->notice('Quant: the project write token changed in the dashboard and was updated from the login.');
+ return TRUE;
+ }
+
+}
diff --git a/modules/quant_api/tests/src/Functional/OAuthConnectTest.php b/modules/quant_api/tests/src/Functional/OAuthConnectTest.php
index 45e5af50..d9819c6d 100644
--- a/modules/quant_api/tests/src/Functional/OAuthConnectTest.php
+++ b/modules/quant_api/tests/src/Functional/OAuthConnectTest.php
@@ -193,4 +193,33 @@ public function testTokenConnectAndPlatform(): void {
$this->assertSame('site-d', $this->config('quant_api.settings')->get('api_project'));
}
+ /**
+ * A cancelled login on another platform leaves the connection as it was.
+ */
+ public function testCancelledLoginKeepsThePlatform(): void {
+ $this->drupalGet('admin/config/development/quant/api');
+ $this->submitForm(['api_account' => 'test-org', 'api_project' => 'site-c', 'api_token' => 'typed-token'], 'Save configuration');
+ \Drupal::state()->set('quant_api_oauth_test.deny', TRUE);
+ $this->submitForm(['platform' => 'quantgov'], 'Log in to Quant');
+ $this->assertSession()->pageTextContains('The login was cancelled');
+ $config = $this->config('quant_api.settings');
+ $this->assertSame('quantcdn', $config->get('platform'));
+ $this->assertSame('https://api.quantcdn.io', $config->get('api_endpoint'));
+ $this->assertSame('typed-token', $config->get('api_token'));
+ }
+
+ /**
+ * A completed login saves the chosen platform and fixes its endpoint.
+ */
+ public function testCompletedLoginSavesThePlatform(): void {
+ $this->drupalGet('admin/config/development/quant/api');
+ $this->submitForm(['platform' => 'quantgov'], 'Log in to Quant');
+ $this->assertSame('quantcdn', $this->config('quant_api.settings')->get('platform'), 'Not saved before the project choice.');
+ $this->submitForm(['project' => 'test-org:site-b'], 'Use this project');
+ $config = $this->config('quant_api.settings');
+ $this->assertSame('quantgov', $config->get('platform'));
+ $this->assertSame('https://api.quantgov.cloud', $config->get('api_endpoint'));
+ $this->assertSession()->elementAttributeExists('css', 'input[name="api_endpoint"]', 'disabled');
+ }
+
}
diff --git a/modules/quant_api/tests/src/Kernel/OAuthAuthServiceTest.php b/modules/quant_api/tests/src/Kernel/OAuthAuthServiceTest.php
index 412b54f6..17e3f19b 100644
--- a/modules/quant_api/tests/src/Kernel/OAuthAuthServiceTest.php
+++ b/modules/quant_api/tests/src/Kernel/OAuthAuthServiceTest.php
@@ -326,4 +326,79 @@ public function testUpdateHookSetsThePlatformFromTheEndpoint(): void {
$this->assertSame('https://dash.quantgov.cloud', $this->auth()->getDashboardUrl());
}
+ /**
+ * A copied database publishes nothing with the original site's login.
+ *
+ * @covers \Drupal\quant_api\Credentials::getToken
+ * @covers \Drupal\quant_api\Credentials::isCopiedSite
+ */
+ public function testCopiedSiteGetsNoToken(): void {
+ $this->connect(86400);
+ $this->config('quant_api.settings')->set('auth_method', 'oauth')->save();
+ $credentials = $this->container->get('quant_api.credentials');
+ $this->assertSame('write-token', $credentials->getToken());
+
+ $fingerprint = $this->store()->get(OAuthTokenStore::FINGERPRINT);
+ $fingerprint['database'] = hash('sha256', 'production database');
+ $this->store()->set(OAuthTokenStore::FINGERPRINT, $fingerprint);
+ $this->assertTrue($credentials->isCopiedSite());
+ $this->assertNull($credentials->getToken());
+ $this->assertFalse($credentials->hasToken());
+ }
+
+ /**
+ * With a login, the endpoint is the platform's own host.
+ *
+ * @covers \Drupal\quant_api\Credentials::getEndpoint
+ */
+ public function testLoginFixesTheEndpoint(): void {
+ $credentials = $this->container->get('quant_api.credentials');
+ $this->config('quant_api.settings')->set('api_endpoint', 'https://collector.example')->save();
+ $this->assertSame('https://collector.example', $credentials->getEndpoint(), 'Token connect keeps a custom endpoint.');
+
+ $this->config('quant_api.settings')->set('auth_method', 'oauth')->set('platform', 'quantgov')->save();
+ $this->assertSame('https://api.quantgov.cloud', $credentials->getEndpoint());
+ $this->config('quant_api.settings')->set('platform', 'quantcdn')->save();
+ $this->assertSame('https://api.quantcdn.io', $credentials->getEndpoint());
+ }
+
+ /**
+ * A write token rotated in the dashboard is re-read with the login.
+ *
+ * @covers \Drupal\quant_api\OAuth\WriteTokenSync::syncIfDue
+ */
+ public function testWriteTokenResync(): void {
+ $this->connect(86400 * 80);
+ $this->config('quant_api.settings')->set('auth_method', 'oauth')
+ ->set('api_account', 'test-org')->set('api_project', 'site-a')->save();
+ $sync = $this->container->get('quant_api.write_token_sync');
+
+ $this->mock->append(self::json(200, ['machine_name' => 'site-a', 'write_token' => 'rotated-token']));
+ $this->assertTrue($sync->syncIfDue(FALSE));
+ $this->assertSame('rotated-token', $this->store()->get(OAuthTokenStore::WRITE_TOKEN));
+ $request = $this->history[0]['request'];
+ $this->assertSame('/api/v2/organizations/test-org/projects/site-a', $request->getUri()->getPath());
+ $this->assertSame('with_token=1', $request->getUri()->getQuery());
+ $this->assertSame('Bearer qca_old', $request->getHeaderLine('Authorization'));
+
+ // Within a day nothing is sent, unless the login was just refreshed.
+ $this->assertNull($sync->syncIfDue(FALSE));
+ $this->assertCount(1, $this->history);
+ $this->mock->append(self::json(200, ['machine_name' => 'site-a', 'write_token' => 'rotated-token']));
+ $this->assertFalse($sync->syncIfDue(TRUE));
+ }
+
+ /**
+ * Cron reports whether it rotated the tokens.
+ *
+ * @covers ::refreshFromCron
+ */
+ public function testCronReportsTheRotation(): void {
+ $this->connect(86400 * 80, [OAuthTokenStore::LIFETIME => 7776000]);
+ $this->assertFalse($this->auth()->refreshFromCron());
+ $this->store()->set(OAuthTokenStore::EXPIRES_AT, time() + 86400);
+ $this->mock->append(self::rotated());
+ $this->assertTrue($this->auth()->refreshFromCron());
+ }
+
}
From f7fa948bee47313f28871657e2c3a7753299b2ae Mon Sep 17 00:00:00 2001
From: Stuart Rowlands
Date: Sun, 8 Nov 2026 16:24:48 +1000
Subject: [PATCH 09/30] fix: a copied site says so and can be confirmed; a 401
re-reads the write token
- A copy (fingerprint mismatch) logs an error that names the differing
components, shows on the status report and the settings page, and offers
"This is the same site" (configure quant), which stores the fingerprint
again so the site publishes. The fingerprint docblock is corrected.
- A 401 from the content API re-reads the project write token with the
login, at most once every 15 minutes, so a token rotated in the dashboard
recovers on the next publish.
---
modules/quant_api/quant_api.install | 25 +++++++++
modules/quant_api/quant_api.services.yml | 5 ++
modules/quant_api/src/Client/QuantClient.php | 8 +++
modules/quant_api/src/Credentials.php | 20 ++++++-
modules/quant_api/src/Form/SettingsForm.php | 27 ++++++++-
.../src/Http/UnauthorizedResyncMiddleware.php | 35 ++++++++++++
.../quant_api/src/OAuth/OAuthTokenStore.php | 3 +
.../quant_api/src/OAuth/SiteFingerprint.php | 7 ++-
.../quant_api/src/OAuth/WriteTokenSync.php | 20 +++++++
.../tests/src/Functional/OAuthConnectTest.php | 17 ++++++
.../tests/src/Kernel/OAuthAuthServiceTest.php | 56 +++++++++++++++++++
11 files changed, 218 insertions(+), 5 deletions(-)
create mode 100644 modules/quant_api/src/Http/UnauthorizedResyncMiddleware.php
diff --git a/modules/quant_api/quant_api.install b/modules/quant_api/quant_api.install
index f2051c5f..9746fd0e 100644
--- a/modules/quant_api/quant_api.install
+++ b/modules/quant_api/quant_api.install
@@ -5,8 +5,33 @@
* Install, update and uninstall functions for the Quant API module.
*/
+use Drupal\Core\Url;
use Drupal\quant_api\OAuth\Platform;
+/**
+ * Implements hook_requirements().
+ */
+function quant_api_requirements($phase) {
+ if ($phase !== 'runtime') {
+ return [];
+ }
+ $credentials = \Drupal::service('quant_api.credentials');
+ if (!$credentials->usesOauth() || !$credentials->isCopiedSite()) {
+ return [];
+ }
+ return [
+ 'quant_api_copied_site' => [
+ 'title' => t('Quant'),
+ 'value' => t('Not publishing: this site looks like a copy of the connected site'),
+ 'description' => t('Different: @components. Log in to Quant again, or confirm that it is the same site on the Quant API settings page.', [
+ '@components' => implode(', ', $credentials->copyDifferences()),
+ ':url' => Url::fromRoute('quant_api.settings_form')->toString(),
+ ]),
+ 'severity' => REQUIREMENT_ERROR,
+ ],
+ ];
+}
+
/**
* Implements hook_uninstall().
*/
diff --git a/modules/quant_api/quant_api.services.yml b/modules/quant_api/quant_api.services.yml
index 4f93fa59..746e592c 100644
--- a/modules/quant_api/quant_api.services.yml
+++ b/modules/quant_api/quant_api.services.yml
@@ -62,3 +62,8 @@ services:
- '@quant_api.credentials'
- '@config.factory'
- '@logger.factory'
+
+ quant_api.unauthorized_resync_middleware:
+ class: Drupal\quant_api\Http\UnauthorizedResyncMiddleware
+ tags:
+ - { name: http_client_middleware }
diff --git a/modules/quant_api/src/Client/QuantClient.php b/modules/quant_api/src/Client/QuantClient.php
index d5059f6f..988b36d2 100644
--- a/modules/quant_api/src/Client/QuantClient.php
+++ b/modules/quant_api/src/Client/QuantClient.php
@@ -144,6 +144,14 @@ protected function refusesWithoutToken(string $operation) : bool {
static $logged = FALSE;
if (!$logged) {
$logged = TRUE;
+ $copy = $this->credentials && $this->credentials->usesOauth($this->config) ? $this->credentials->copyDifferences() : [];
+ if ($copy !== []) {
+ $this->logger->error('Quant: this site looks like a copy of the connected site (different: @components), so nothing is published (@operation). Log in to Quant again, or confirm it is the same site on the Quant API settings page.', [
+ '@components' => implode(', ', $copy),
+ '@operation' => $operation,
+ ]);
+ return TRUE;
+ }
$this->logger->warning('Quant API: no project token is set, so the module did not call the API (@operation). Connect the site on the Quant API settings page.', [
'@operation' => $operation,
]);
diff --git a/modules/quant_api/src/Credentials.php b/modules/quant_api/src/Credentials.php
index a507802a..d245a7c4 100644
--- a/modules/quant_api/src/Credentials.php
+++ b/modules/quant_api/src/Credentials.php
@@ -40,8 +40,26 @@ public function __construct(
* edits would reach the production project.
*/
public function isCopiedSite(): bool {
+ return $this->copyDifferences() !== [];
+ }
+
+ /**
+ * The fingerprint components that differ from the connected site.
+ *
+ * @return string[]
+ * Component names (database, prefix, hash_salt, uuid, env:...), never
+ * values; empty when this is the connected site.
+ */
+ public function copyDifferences(): array {
$stored = $this->tokenStore->get(OAuthTokenStore::FINGERPRINT);
- return is_array($stored) && $this->fingerprint->differences($stored) !== [];
+ return is_array($stored) ? $this->fingerprint->differences($stored) : [];
+ }
+
+ /**
+ * Confirms that this is the connected site: stores its fingerprint again.
+ */
+ public function confirmSameSite(): void {
+ $this->tokenStore->set(OAuthTokenStore::FINGERPRINT, $this->fingerprint->compute());
}
/**
diff --git a/modules/quant_api/src/Form/SettingsForm.php b/modules/quant_api/src/Form/SettingsForm.php
index 5c35c174..efce1b2b 100644
--- a/modules/quant_api/src/Form/SettingsForm.php
+++ b/modules/quant_api/src/Form/SettingsForm.php
@@ -155,6 +155,14 @@ protected function buildConnected($config): array {
'#markup' => '' . $this->reconnectMessage($reason) . '
',
];
}
+ if ($reason === AuthService::RECONNECT_OTHER_SITE) {
+ $items['same_site'] = [
+ '#type' => 'submit',
+ '#value' => $this->t('This is the same site'),
+ '#submit' => ['::submitSameSite'],
+ '#limit_validation_errors' => [],
+ ];
+ }
$items['change'] = [
'#type' => 'link',
'#title' => $this->t('Change project'),
@@ -178,7 +186,7 @@ protected function buildConnected($config): array {
*/
protected function reconnectMessage(string $reason) {
return match ($reason) {
- AuthService::RECONNECT_OTHER_SITE => $this->t('The stored login belongs to another copy of this site (for example a database copied from production). This copy does not publish or refresh with it. Log in to Quant on this copy.'),
+ AuthService::RECONNECT_OTHER_SITE => $this->t('This site looks like a copy of the connected site (for example a database copied from production), so it does not publish or refresh with the stored login. Log in to Quant on this copy. If it is the same site, confirm it below.'),
AuthService::RECONNECT_REFUSED => $this->t('Quant refused to refresh the login. Content still publishes. Log in to Quant again to keep the connection current.'),
default => $this->t('The login expired. Content still publishes. Log in to Quant again to keep the connection current.'),
};
@@ -254,7 +262,9 @@ protected function buildAdvanced($config): array {
*/
protected function showStatus(): void {
if ($this->credentials->usesOauth() && $this->credentials->isCopiedSite()) {
- $this->messenger()->addError($this->t('This database was copied from another site, so this copy does not publish with the Quant login of that site. Log in to Quant on this copy, or connect it with a project token.'));
+ $this->messenger()->addError($this->t('This site looks like a copy of the connected site (different: @components), so nothing is published. Log in to Quant again, or, if this is the same site (for example after a database move), click "This is the same site".', [
+ '@components' => implode(', ', $this->credentials->copyDifferences()),
+ ]));
return;
}
if (!$this->credentials->hasToken()) {
@@ -350,6 +360,19 @@ protected function resolveEndpoint(FormStateInterface $form_state): string {
return ($endpoint === '' || in_array($endpoint, $defaults, TRUE)) ? Platform::apiEndpoint($platform) : $endpoint;
}
+ /**
+ * Confirms that this is the connected site, so it publishes again.
+ */
+ public function submitSameSite(array &$form, FormStateInterface $form_state) {
+ $differences = $this->credentials->copyDifferences();
+ $this->credentials->confirmSameSite();
+ $this->logger('quant_api')->notice('Quant: @user confirmed that this is the connected site (was different: @components).', [
+ '@user' => $this->currentUser()->getAccountName(),
+ '@components' => implode(', ', $differences),
+ ]);
+ $this->messenger()->addStatus($this->t('Confirmed. This site publishes with its Quant login again.'));
+ }
+
/**
* Saves the platform and starts the login.
*/
diff --git a/modules/quant_api/src/Http/UnauthorizedResyncMiddleware.php b/modules/quant_api/src/Http/UnauthorizedResyncMiddleware.php
new file mode 100644
index 00000000..996c74aa
--- /dev/null
+++ b/modules/quant_api/src/Http/UnauthorizedResyncMiddleware.php
@@ -0,0 +1,35 @@
+then(function (ResponseInterface $response) use ($request) {
+ if ($response->getStatusCode() === 401 && $request->hasHeader('Quant-Token')) {
+ // Lazy: the sync service itself uses the HTTP client.
+ \Drupal::service('quant_api.write_token_sync')->syncAfterUnauthorized();
+ }
+ return $response;
+ });
+ };
+ };
+ }
+
+}
diff --git a/modules/quant_api/src/OAuth/OAuthTokenStore.php b/modules/quant_api/src/OAuth/OAuthTokenStore.php
index 62f9afc4..a2e3d7be 100644
--- a/modules/quant_api/src/OAuth/OAuthTokenStore.php
+++ b/modules/quant_api/src/OAuth/OAuthTokenStore.php
@@ -54,6 +54,8 @@ class OAuthTokenStore {
public const SYNCED_AT = 'synced_at';
+ public const UNAUTHORIZED_AT = 'unauthorized_at';
+
protected const NAMES = [
self::ACCESS_TOKEN,
self::REFRESH_TOKEN,
@@ -67,6 +69,7 @@ class OAuthTokenStore {
self::CRON_RETRY_AFTER,
self::PENDING,
self::SYNCED_AT,
+ self::UNAUTHORIZED_AT,
];
/**
diff --git a/modules/quant_api/src/OAuth/SiteFingerprint.php b/modules/quant_api/src/OAuth/SiteFingerprint.php
index e16dbc32..970898ff 100644
--- a/modules/quant_api/src/OAuth/SiteFingerprint.php
+++ b/modules/quant_api/src/OAuth/SiteFingerprint.php
@@ -14,8 +14,11 @@
* database that refreshed "its" tokens would therefore disconnect production.
* The stored fingerprint lets the copy see that the tokens are not its own.
*
- * Only the refresh is blocked. Publishing with the project write token is the
- * same as with a token typed into the form, as before 2.3.
+ * On a copy, the login is neither refreshed nor used to publish
+ * (Credentials::getToken() returns NULL), so staging content never reaches
+ * the production project. An administrator who knows it is the same site (a
+ * new database name, a new hash salt) confirms it with "This is the same
+ * site" on the Quant API settings page, which stores the fingerprint again.
*/
class SiteFingerprint {
diff --git a/modules/quant_api/src/OAuth/WriteTokenSync.php b/modules/quant_api/src/OAuth/WriteTokenSync.php
index 52e471c0..4339adf9 100644
--- a/modules/quant_api/src/OAuth/WriteTokenSync.php
+++ b/modules/quant_api/src/OAuth/WriteTokenSync.php
@@ -21,6 +21,11 @@ class WriteTokenSync {
*/
public const INTERVAL = 86400;
+ /**
+ * The least time between two syncs started by a 401, in seconds.
+ */
+ public const UNAUTHORIZED_INTERVAL = 900;
+
/**
* The logger.
*/
@@ -48,6 +53,21 @@ public function syncIfDue(bool $refreshed): ?bool {
return $this->sync();
}
+ /**
+ * Syncs after the content API refused the write token (a 401).
+ *
+ * At most once every 15 minutes, so a token that stays refused does not
+ * call the dashboard on every request.
+ */
+ public function syncAfterUnauthorized(): ?bool {
+ $last = (int) $this->tokenStore->get(OAuthTokenStore::UNAUTHORIZED_AT);
+ if ($last > time() - self::UNAUTHORIZED_INTERVAL || !$this->credentials->usesOauth()) {
+ return NULL;
+ }
+ $this->tokenStore->set(OAuthTokenStore::UNAUTHORIZED_AT, time());
+ return $this->sync();
+ }
+
/**
* Re-reads the write token.
*
diff --git a/modules/quant_api/tests/src/Functional/OAuthConnectTest.php b/modules/quant_api/tests/src/Functional/OAuthConnectTest.php
index d9819c6d..368add31 100644
--- a/modules/quant_api/tests/src/Functional/OAuthConnectTest.php
+++ b/modules/quant_api/tests/src/Functional/OAuthConnectTest.php
@@ -222,4 +222,21 @@ public function testCompletedLoginSavesThePlatform(): void {
$this->assertSession()->elementAttributeExists('css', 'input[name="api_endpoint"]', 'disabled');
}
+ /**
+ * A copied site says so and can be confirmed as the same site.
+ */
+ public function testCopiedSiteCanBeConfirmed(): void {
+ $this->logInAndPick('site-b');
+ $store = \Drupal::service('quant_api.oauth_store');
+ $fingerprint = $store->get(OAuthTokenStore::FINGERPRINT);
+ $fingerprint['database'] = hash('sha256', 'old database');
+ $store->set(OAuthTokenStore::FINGERPRINT, $fingerprint);
+
+ $this->drupalGet('admin/config/development/quant/api');
+ $this->assertSession()->pageTextContains('This site looks like a copy of the connected site (different: database)');
+ $this->submitForm([], 'This is the same site');
+ $this->assertSession()->pageTextContains('Confirmed. This site publishes with its Quant login again.');
+ $this->assertSession()->pageTextContains('Connected to the Quant project site-b.');
+ }
+
}
diff --git a/modules/quant_api/tests/src/Kernel/OAuthAuthServiceTest.php b/modules/quant_api/tests/src/Kernel/OAuthAuthServiceTest.php
index 17e3f19b..0feae68c 100644
--- a/modules/quant_api/tests/src/Kernel/OAuthAuthServiceTest.php
+++ b/modules/quant_api/tests/src/Kernel/OAuthAuthServiceTest.php
@@ -2,6 +2,8 @@
namespace Drupal\Tests\quant_api\Kernel;
+use GuzzleHttp\Promise\Create;
+use Drupal\quant_api\Http\UnauthorizedResyncMiddleware;
use Drupal\KernelTests\KernelTestBase;
use Drupal\quant_api\OAuth\OAuthTokenStore;
use Drupal\quant_api\OAuth\Platform;
@@ -401,4 +403,58 @@ public function testCronReportsTheRotation(): void {
$this->assertTrue($this->auth()->refreshFromCron());
}
+ /**
+ * A 401 from the content API re-reads the write token, once per 15 minutes.
+ *
+ * @covers \Drupal\quant_api\Http\UnauthorizedResyncMiddleware
+ * @covers \Drupal\quant_api\OAuth\WriteTokenSync::syncAfterUnauthorized
+ */
+ public function testUnauthorizedTriggersOneResync(): void {
+ $this->connect(86400 * 80);
+ $this->config('quant_api.settings')->set('auth_method', 'oauth')
+ ->set('api_account', 'test-org')->set('api_project', 'site-a')->save();
+ $this->store()->set(OAuthTokenStore::SYNCED_AT, time());
+ $middleware = (new UnauthorizedResyncMiddleware())();
+ $refused = fn () => Create::promiseFor(new Response(401));
+ $call = fn () => $middleware($refused)(new Request('POST', 'https://api.quantcdn.io/v1', ['Quant-Token' => 'old']), [])->wait();
+
+ $this->mock->append(self::json(200, ['machine_name' => 'site-a', 'write_token' => 'rotated-token']));
+ $this->assertSame(401, $call()->getStatusCode());
+ $this->assertSame('rotated-token', $this->store()->get(OAuthTokenStore::WRITE_TOKEN));
+ $this->assertCount(1, $this->history);
+
+ // A second 401 within 15 minutes does not call the dashboard again.
+ $call();
+ $this->assertCount(1, $this->history);
+
+ // A 401 from a call without Quant-Token (the dashboard itself) is ignored.
+ $this->store()->delete([OAuthTokenStore::UNAUTHORIZED_AT]);
+ $middleware($refused)(new Request('GET', 'https://dashboard.quantcdn.io/api/oauth/user'), [])->wait();
+ $this->assertCount(1, $this->history);
+ }
+
+ /**
+ * A copy is explicit, names what differs, and recovers with "same site".
+ *
+ * @covers \Drupal\quant_api\Credentials::confirmSameSite
+ */
+ public function testSameSiteConfirmationRestoresPublishing(): void {
+ $this->connect(86400);
+ $this->config('quant_api.settings')->set('auth_method', 'oauth')->save();
+ $credentials = $this->container->get('quant_api.credentials');
+ $fingerprint = $this->store()->get(OAuthTokenStore::FINGERPRINT);
+ $fingerprint['database'] = hash('sha256', 'old database');
+ $this->store()->set(OAuthTokenStore::FINGERPRINT, $fingerprint);
+ $this->assertSame(['database'], $credentials->copyDifferences());
+ $this->assertNull($credentials->getToken());
+
+ \Drupal::moduleHandler()->loadInclude('quant_api', 'install');
+ $requirements = quant_api_requirements('runtime');
+ $this->assertSame(REQUIREMENT_ERROR, $requirements['quant_api_copied_site']['severity']);
+
+ $credentials->confirmSameSite();
+ $this->assertSame([], $credentials->copyDifferences());
+ $this->assertSame('write-token', $credentials->getToken());
+ }
+
}
From 0ee3376d48427139e83cff5b15fb65e4c2d014c2 Mon Sep 17 00:00:00 2001
From: Stuart Rowlands
Date: Sun, 8 Nov 2026 17:14:17 +1000
Subject: [PATCH 10/30] feat: settings.php overrides for the content API
endpoint and the server-side dashboard
- $settings['quant_api_endpoint_url'] names the content API endpoint in
every mode (for example a stage host behind basic auth). It is never
editable in the UI; the settings page and drush show it without its
userinfo, and an HTTP middleware moves userinfo from any request URI into
an Authorization header before the request is sent, so it never reaches a
message or a log.
- $settings['quant_api_dashboard_internal_url'] is the dashboard address for
server-to-server calls (token, refresh, projects, limit, write token); the
browser redirect keeps quant_api_dashboard_url. Default: the same.
---
modules/quant_api/quant_api.services.yml | 5 ++
.../src/Commands/QuantApiAuthCommands.php | 2 +-
modules/quant_api/src/Credentials.php | 28 +++++++++++
modules/quant_api/src/Form/SettingsForm.php | 16 +++++--
.../src/Http/EndpointUserInfoMiddleware.php | 33 +++++++++++++
modules/quant_api/src/OAuth/AuthService.php | 11 ++++-
modules/quant_api/src/OAuth/Platform.php | 13 ++++++
modules/quant_api/src/OAuth/PortalClient.php | 2 +-
.../tests/src/Functional/OAuthConnectTest.php | 21 +++++++++
.../tests/src/Kernel/OAuthAuthServiceTest.php | 46 +++++++++++++++++++
10 files changed, 169 insertions(+), 8 deletions(-)
create mode 100644 modules/quant_api/src/Http/EndpointUserInfoMiddleware.php
diff --git a/modules/quant_api/quant_api.services.yml b/modules/quant_api/quant_api.services.yml
index 746e592c..0a4d6256 100644
--- a/modules/quant_api/quant_api.services.yml
+++ b/modules/quant_api/quant_api.services.yml
@@ -67,3 +67,8 @@ services:
class: Drupal\quant_api\Http\UnauthorizedResyncMiddleware
tags:
- { name: http_client_middleware }
+
+ quant_api.endpoint_userinfo_middleware:
+ class: Drupal\quant_api\Http\EndpointUserInfoMiddleware
+ tags:
+ - { name: http_client_middleware }
diff --git a/modules/quant_api/src/Commands/QuantApiAuthCommands.php b/modules/quant_api/src/Commands/QuantApiAuthCommands.php
index 8b1f3790..b445ecdc 100644
--- a/modules/quant_api/src/Commands/QuantApiAuthCommands.php
+++ b/modules/quant_api/src/Commands/QuantApiAuthCommands.php
@@ -34,7 +34,7 @@ public function authStatus() {
$oauth = $this->credentials->usesOauth($config) && !$config->hasOverrides('api_token');
$rows = [
'Platform' => (string) $config->get('platform'),
- 'API endpoint' => (string) $config->get('api_endpoint'),
+ 'API endpoint' => Credentials::redact($this->credentials->getEndpoint($config)) . (Credentials::endpointOverride() !== '' ? ' (settings.php)' : ''),
'Organization' => (string) $config->get('api_account'),
'Project' => (string) $config->get('api_project'),
'Method' => $oauth ? 'Log in to Quant' : ($config->hasOverrides('api_token') ? 'Project token (settings.php)' : 'Project token'),
diff --git a/modules/quant_api/src/Credentials.php b/modules/quant_api/src/Credentials.php
index d245a7c4..922e9c36 100644
--- a/modules/quant_api/src/Credentials.php
+++ b/modules/quant_api/src/Credentials.php
@@ -5,6 +5,7 @@
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Config\Config;
use Drupal\Core\Config\StorageInterface;
+use Drupal\Core\Site\Settings;
use Drupal\quant_api\OAuth\OAuthTokenStore;
use Drupal\quant_api\OAuth\Platform;
use Drupal\quant_api\OAuth\SiteFingerprint;
@@ -62,6 +63,29 @@ public function confirmSameSite(): void {
$this->tokenStore->set(OAuthTokenStore::FINGERPRINT, $this->fingerprint->compute());
}
+ /**
+ * The endpoint from $settings['quant_api_endpoint_url'], or an empty string.
+ *
+ * Set only in settings.php, never in the UI. It may carry basic-auth
+ * userinfo (https://user:password@host); the HTTP client moves that into a
+ * header, and redact() keeps it off every page.
+ */
+ public static function endpointOverride(): string {
+ return rtrim(trim((string) Settings::get('quant_api_endpoint_url', '')), '/');
+ }
+
+ /**
+ * An endpoint without its userinfo, for display.
+ */
+ public static function redact(string $endpoint): string {
+ $parts = parse_url($endpoint);
+ if (!is_array($parts) || empty($parts['host'])) {
+ return '';
+ }
+ $port = isset($parts['port']) ? ':' . $parts['port'] : '';
+ return ($parts['scheme'] ?? 'https') . '://' . $parts['host'] . $port . ($parts['path'] ?? '');
+ }
+
/**
* The content API endpoint, without /v1.
*
@@ -70,6 +94,10 @@ public function confirmSameSite(): void {
* override still wins.
*/
public function getEndpoint(?Config $config = NULL): string {
+ $override = self::endpointOverride();
+ if ($override !== '') {
+ return $override;
+ }
$config = $config ?? $this->configFactory->get('quant_api.settings');
if ($this->usesOauth($config) && !$config->hasOverrides('api_endpoint')) {
return Platform::apiEndpoint((string) $config->get('platform'));
diff --git a/modules/quant_api/src/Form/SettingsForm.php b/modules/quant_api/src/Form/SettingsForm.php
index efce1b2b..c9ed26c7 100644
--- a/modules/quant_api/src/Form/SettingsForm.php
+++ b/modules/quant_api/src/Form/SettingsForm.php
@@ -234,6 +234,7 @@ protected function buildTokenConnect($config): array {
*/
protected function buildAdvanced($config): array {
$oauth = $this->credentials->usesOauth($config);
+ $override = Credentials::endpointOverride() !== '';
return [
'#type' => 'details',
'#title' => $this->t('Advanced'),
@@ -241,11 +242,14 @@ protected function buildAdvanced($config): array {
'#type' => 'textfield',
'#title' => $this->t('API Endpoint'),
'#description' => $this->t('Leave the platform default unless Quant support gives you another endpoint. Use https and do not add /v1; the module adds it.'),
- '#default_value' => $this->credentials->getEndpoint($config) ?: Platform::apiEndpoint(Platform::QUANTCDN),
+ '#default_value' => Credentials::redact($this->credentials->getEndpoint($config)) ?: Platform::apiEndpoint(Platform::QUANTCDN),
'#parents' => ['api_endpoint'],
- // A login publishes only to the platform's own host.
- '#disabled' => $oauth,
- '#field_suffix' => $oauth ? $this->t('Fixed to the platform while this site is logged in to Quant.') : NULL,
+ // A login publishes only to the platform's own host, unless
+ // settings.php names another endpoint.
+ '#disabled' => $oauth || $override,
+ '#field_suffix' => $override
+ ? $this->t("Set in settings.php (\$settings['quant_api_endpoint_url']).")
+ : ($oauth ? $this->t('Fixed to the platform while this site is logged in to Quant.') : NULL),
],
'api_tls_disabled' => [
'#type' => 'checkbox',
@@ -353,6 +357,10 @@ protected function validateTokenConnect(FormStateInterface $form_state): void {
* platform, so switching the platform switches the endpoint.
*/
protected function resolveEndpoint(FormStateInterface $form_state): string {
+ if (Credentials::endpointOverride() !== '') {
+ // settings.php decides; the stored value stays as it is.
+ return (string) ($this->config(self::SETTINGS)->get('api_endpoint') ?: Platform::apiEndpoint(Platform::QUANTCDN));
+ }
$platform = (string) $form_state->getValue('platform');
$platform = Platform::isKnown($platform) ? $platform : Platform::QUANTCDN;
$endpoint = EndpointValidator::normalise($form_state->getValue('api_endpoint'));
diff --git a/modules/quant_api/src/Http/EndpointUserInfoMiddleware.php b/modules/quant_api/src/Http/EndpointUserInfoMiddleware.php
new file mode 100644
index 00000000..52f82d88
--- /dev/null
+++ b/modules/quant_api/src/Http/EndpointUserInfoMiddleware.php
@@ -0,0 +1,33 @@
+getUri()->getUserInfo();
+ if ($userinfo !== '') {
+ $request = $request
+ ->withUri($request->getUri()->withUserInfo(''))
+ ->withHeader('Authorization', 'Basic ' . base64_encode(rawurldecode($userinfo)));
+ }
+ return $handler($request, $options);
+ };
+ };
+ }
+
+}
diff --git a/modules/quant_api/src/OAuth/AuthService.php b/modules/quant_api/src/OAuth/AuthService.php
index b84c2396..d7df0e5a 100644
--- a/modules/quant_api/src/OAuth/AuthService.php
+++ b/modules/quant_api/src/OAuth/AuthService.php
@@ -113,6 +113,13 @@ public function getDashboardUrl(): string {
return Platform::dashboardUrl($this->getPlatform());
}
+ /**
+ * The dashboard for server-to-server calls.
+ */
+ public function getInternalDashboardUrl(): string {
+ return Platform::internalDashboardUrl($this->getPlatform());
+ }
+
/**
* A new PKCE code verifier: 32 random bytes, base64url.
*/
@@ -150,7 +157,7 @@ public function getAuthorizationUrl(string $state, string $redirect_uri, string
*/
public function exchangeCode(string $code, string $redirect_uri, string $code_verifier): ?array {
try {
- $response = $this->httpClient->request('POST', $this->getDashboardUrl() . '/oauth/token', [
+ $response = $this->httpClient->request('POST', $this->getInternalDashboardUrl() . '/oauth/token', [
'form_params' => [
'grant_type' => 'authorization_code',
'client_id' => Platform::CLIENT_ID,
@@ -432,7 +439,7 @@ protected function sendRefresh(string $refresh_token): array {
*/
protected function requestRefresh(string $refresh_token): array {
try {
- $response = $this->httpClient->request('POST', $this->getDashboardUrl() . '/oauth/token', [
+ $response = $this->httpClient->request('POST', $this->getInternalDashboardUrl() . '/oauth/token', [
'form_params' => [
'grant_type' => 'refresh_token',
'refresh_token' => $refresh_token,
diff --git a/modules/quant_api/src/OAuth/Platform.php b/modules/quant_api/src/OAuth/Platform.php
index ecf18db4..ba7695f1 100644
--- a/modules/quant_api/src/OAuth/Platform.php
+++ b/modules/quant_api/src/OAuth/Platform.php
@@ -73,6 +73,19 @@ public static function dashboardUrl(string $platform): string {
return self::PLATFORMS[$platform]['dashboard'] ?? self::PLATFORMS[self::QUANTCDN]['dashboard'];
}
+ /**
+ * The dashboard for server-to-server calls (token, refresh, projects).
+ *
+ * $settings['quant_api_dashboard_internal_url'] wins, for a site whose
+ * server reaches the dashboard at another address than the browser does
+ * (for example a container that reaches the host at host.docker.internal).
+ * The browser redirect always uses dashboardUrl().
+ */
+ public static function internalDashboardUrl(string $platform): string {
+ $internal = (string) Settings::get('quant_api_dashboard_internal_url', '');
+ return $internal !== '' ? rtrim($internal, '/') : self::dashboardUrl($platform);
+ }
+
/**
* The platform of an API endpoint, for sites configured before 2.3.
*
diff --git a/modules/quant_api/src/OAuth/PortalClient.php b/modules/quant_api/src/OAuth/PortalClient.php
index ea2cb60e..d6577aa1 100644
--- a/modules/quant_api/src/OAuth/PortalClient.php
+++ b/modules/quant_api/src/OAuth/PortalClient.php
@@ -121,7 +121,7 @@ protected function send(string $method, string $path, string $access_token, ?arr
$options['json'] = $json;
}
try {
- $response = $this->httpClient->request($method, $this->auth->getDashboardUrl() . $path, $options);
+ $response = $this->httpClient->request($method, $this->auth->getInternalDashboardUrl() . $path, $options);
}
catch (\Exception $e) {
$this->logger->error('The Quant dashboard call @method @path failed (@class).', [
diff --git a/modules/quant_api/tests/src/Functional/OAuthConnectTest.php b/modules/quant_api/tests/src/Functional/OAuthConnectTest.php
index 368add31..209affa4 100644
--- a/modules/quant_api/tests/src/Functional/OAuthConnectTest.php
+++ b/modules/quant_api/tests/src/Functional/OAuthConnectTest.php
@@ -239,4 +239,25 @@ public function testCopiedSiteCanBeConfirmed(): void {
$this->assertSession()->pageTextContains('Connected to the Quant project site-b.');
}
+ /**
+ * The settings.php endpoint is read-only and never shows its userinfo.
+ */
+ public function testEndpointOverrideIsReadOnly(): void {
+ $this->writeSettings([
+ 'settings' => [
+ 'quant_api_endpoint_url' => (object) [
+ 'value' => 'https://rehearsal:placeholder-pass@uploads.stage.example',
+ 'required' => TRUE,
+ ],
+ ],
+ ]);
+ $this->drupalGet('admin/config/development/quant/api');
+ $session = $this->assertSession();
+ $session->elementAttributeExists('css', 'input[name="api_endpoint"]', 'disabled');
+ $session->fieldValueEquals('api_endpoint', 'https://uploads.stage.example');
+ $session->pageTextContains('Set in settings.php');
+ $session->responseNotContains('placeholder-pass');
+ $session->responseNotContains('rehearsal:');
+ }
+
}
diff --git a/modules/quant_api/tests/src/Kernel/OAuthAuthServiceTest.php b/modules/quant_api/tests/src/Kernel/OAuthAuthServiceTest.php
index 0feae68c..4565f9cf 100644
--- a/modules/quant_api/tests/src/Kernel/OAuthAuthServiceTest.php
+++ b/modules/quant_api/tests/src/Kernel/OAuthAuthServiceTest.php
@@ -2,6 +2,8 @@
namespace Drupal\Tests\quant_api\Kernel;
+use Drupal\quant_api\Http\EndpointUserInfoMiddleware;
+use Drupal\quant_api\Credentials;
use GuzzleHttp\Promise\Create;
use Drupal\quant_api\Http\UnauthorizedResyncMiddleware;
use Drupal\KernelTests\KernelTestBase;
@@ -457,4 +459,48 @@ public function testSameSiteConfirmationRestoresPublishing(): void {
$this->assertSame('write-token', $credentials->getToken());
}
+ /**
+ * The settings.php endpoint wins in every mode and is shown without userinfo.
+ *
+ * @covers \Drupal\quant_api\Credentials::getEndpoint
+ * @covers \Drupal\quant_api\Credentials::redact
+ * @covers \Drupal\quant_api\Http\EndpointUserInfoMiddleware
+ */
+ public function testEndpointOverrideWithUserInfo(): void {
+ $this->setSetting('quant_api_endpoint_url', 'https://rehearsal:placeholder-pass@uploads.stage.example/');
+ $credentials = $this->container->get('quant_api.credentials');
+ $this->config('quant_api.settings')->set('auth_method', 'oauth')->set('platform', 'quantgov')->save();
+ $this->assertSame('https://rehearsal:placeholder-pass@uploads.stage.example', $credentials->getEndpoint());
+ $this->assertSame('https://uploads.stage.example', Credentials::redact($credentials->getEndpoint()));
+
+ $sent = NULL;
+ $middleware = (new EndpointUserInfoMiddleware())();
+ $handler = function ($request) use (&$sent) {
+ $sent = $request;
+ return Create::promiseFor(new Response(200));
+ };
+ $middleware($handler)(new Request('GET', 'https://rehearsal:placeholder-pass@uploads.stage.example/v1/ping'), [])->wait();
+ $this->assertSame('https://uploads.stage.example/v1/ping', (string) $sent->getUri());
+ $this->assertSame('Basic ' . base64_encode('rehearsal:placeholder-pass'), $sent->getHeaderLine('Authorization'));
+ }
+
+ /**
+ * Server-to-server calls use the internal dashboard; the browser does not.
+ *
+ * @covers ::getInternalDashboardUrl
+ */
+ public function testInternalDashboardForServerCalls(): void {
+ $this->setSetting('quant_api_dashboard_url', 'http://localhost:8002');
+ $this->setSetting('quant_api_dashboard_internal_url', 'http://host.docker.internal:8002/');
+ $this->assertStringStartsWith('http://localhost:8002/oauth/authorize?', $this->auth()->getAuthorizationUrl('st', 'https://site.example/cb', 'ch'));
+
+ $this->mock->append(self::json(200, ['access_token' => 'qca_x', 'refresh_token' => 'qcr_x', 'expires_in' => 60]));
+ $this->auth()->exchangeCode('code', 'https://site.example/cb', 'verifier');
+ $this->assertSame('http://host.docker.internal:8002/oauth/token', (string) $this->history[0]['request']->getUri());
+
+ // Without the internal setting, both are the same.
+ $this->setSetting('quant_api_dashboard_internal_url', '');
+ $this->assertSame('http://localhost:8002', $this->auth()->getInternalDashboardUrl());
+ }
+
}
From ad3eb5f7898fa05dbdfd60727ddbb3b1d5feaae5 Mon Sep 17 00:00:00 2001
From: Stuart Rowlands
Date: Mon, 9 Nov 2026 01:09:32 +1000
Subject: [PATCH 11/30] fix(quant_api): read the approved organisations from
/api/oauth/user
The dashboard token response names the user but not the organisations, so a
real login found no projects. The test dashboard now answers like the real
one. A dashboard fixed in settings.php also sets the starting platform.
---
.../src/Controller/OAuthController.php | 5 ++++-
modules/quant_api/src/Form/SettingsForm.php | 17 ++++++++++++++++-
.../src/FakeQuantMiddleware.php | 10 +++++++++-
.../tests/src/Functional/OAuthConnectTest.php | 16 ++++++++++++++++
4 files changed, 45 insertions(+), 3 deletions(-)
diff --git a/modules/quant_api/src/Controller/OAuthController.php b/modules/quant_api/src/Controller/OAuthController.php
index 25612e7f..65c5f247 100644
--- a/modules/quant_api/src/Controller/OAuthController.php
+++ b/modules/quant_api/src/Controller/OAuthController.php
@@ -107,8 +107,11 @@ public function callback(Request $request) {
return $this->redirectToSettings();
}
- $user = is_array($tokens['user'] ?? NULL) ? $tokens['user'] : $this->portal->getUser($tokens['access_token']);
+ // The token response names the user but not the organisations; only
+ // GET /api/oauth/user lists the organisations the editor approved.
+ $token_user = is_array($tokens['user'] ?? NULL) ? $tokens['user'] : [];
unset($tokens['user']);
+ $user = $this->portal->getUser($tokens['access_token']) ?? $token_user;
$this->tokenStore->savePendingLogin((int) $this->currentUser()->id(), [
'tokens' => $tokens,
'user' => ['name' => (string) ($user['name'] ?? ''), 'email' => (string) ($user['email'] ?? '')],
diff --git a/modules/quant_api/src/Form/SettingsForm.php b/modules/quant_api/src/Form/SettingsForm.php
index c9ed26c7..5312fcaf 100644
--- a/modules/quant_api/src/Form/SettingsForm.php
+++ b/modules/quant_api/src/Form/SettingsForm.php
@@ -4,6 +4,7 @@
use Drupal\Core\Form\ConfigFormBase;
use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Site\Settings;
use Drupal\Core\Url;
use Drupal\quant_api\Controller\OAuthController;
use Drupal\quant_api\Credentials;
@@ -94,7 +95,7 @@ public function buildForm(array $form, FormStateInterface $form_state) {
Platform::QUANTCDN => $this->t('QuantCDN'),
Platform::QUANTGOV => $this->t('QuantGov (Australian government)'),
],
- '#default_value' => Platform::isKnown($config->get('platform')) ? $config->get('platform') : Platform::fromEndpoint($config->get('api_endpoint')),
+ '#default_value' => $this->defaultPlatform($config->get('platform'), (string) $config->get('api_endpoint')),
'#description' => $this->t('Your Quant account is on one platform. QuantGov accounts sign in at dash.quantgov.cloud.'),
];
@@ -451,4 +452,18 @@ protected function savePlatform(FormStateInterface $form_state) {
->set('api_endpoint', $oauth ? Platform::apiEndpoint($platform) : $this->resolveEndpoint($form_state));
}
+ /**
+ * The platform the form starts on.
+ *
+ * A dashboard fixed in settings.php decides it, because logins go there
+ * whatever the editor picks. Otherwise the saved platform, then the endpoint.
+ */
+ protected function defaultPlatform(?string $saved, string $endpoint): string {
+ $dashboard = (string) Settings::get('quant_api_dashboard_url', '');
+ if ($dashboard !== '') {
+ return Platform::fromEndpoint($dashboard);
+ }
+ return Platform::isKnown($saved) ? $saved : Platform::fromEndpoint($endpoint);
+ }
+
}
diff --git a/modules/quant_api/tests/modules/quant_api_oauth_test/src/FakeQuantMiddleware.php b/modules/quant_api/tests/modules/quant_api_oauth_test/src/FakeQuantMiddleware.php
index 26ccff44..2594f546 100644
--- a/modules/quant_api/tests/modules/quant_api_oauth_test/src/FakeQuantMiddleware.php
+++ b/modules/quant_api/tests/modules/quant_api_oauth_test/src/FakeQuantMiddleware.php
@@ -64,6 +64,12 @@ protected function dashboard(RequestInterface $request, string $path): Response
['machine_name' => 'site-a', 'name' => 'Site A'],
['machine_name' => 'site-b', 'name' => 'Site B'],
])),
+ $method === 'GET' && $path === '/api/oauth/user' => $this->bearer($request, self::json(200, [
+ 'id' => 7,
+ 'name' => 'Test Editor',
+ 'email' => 'editor@example.com',
+ 'organizations' => [['id' => 3, 'machine_name' => 'test-org', 'name' => 'Test Org', 'roles' => []]],
+ ])),
$method === 'POST' && $path === '/api/oauth/token/project' => $this->bearer($request, $this->limit($request)),
$method === 'GET' && preg_match('#^/api/v2/organizations/test-org/projects/([a-z-]+)$#', $path, $m) === 1 => $this->bearer($request, $this->project($m[1])),
default => self::json(404, ['message' => 'Not found']),
@@ -87,10 +93,12 @@ protected function token(RequestInterface $request): Response {
'token_type' => 'Bearer',
'expires_in' => 7776000,
'scope' => 'projects:read',
+ // The real dashboard sends only id, email and name here. The
+ // organisations come from GET /api/oauth/user.
'user' => [
+ 'id' => 7,
'name' => 'Test Editor',
'email' => 'editor@example.com',
- 'organizations' => [['machine_name' => 'test-org', 'name' => 'Test Org']],
],
]);
}
diff --git a/modules/quant_api/tests/src/Functional/OAuthConnectTest.php b/modules/quant_api/tests/src/Functional/OAuthConnectTest.php
index 209affa4..d101f860 100644
--- a/modules/quant_api/tests/src/Functional/OAuthConnectTest.php
+++ b/modules/quant_api/tests/src/Functional/OAuthConnectTest.php
@@ -260,4 +260,20 @@ public function testEndpointOverrideIsReadOnly(): void {
$session->responseNotContains('rehearsal:');
}
+ /**
+ * A QuantGov dashboard in settings.php makes QuantGov the starting platform.
+ */
+ public function testDashboardOverrideSetsTheDefaultPlatform(): void {
+ $this->writeSettings([
+ 'settings' => [
+ 'quant_api_dashboard_url' => (object) [
+ 'value' => 'https://dash.stage.quantgov.cloud',
+ 'required' => TRUE,
+ ],
+ ],
+ ]);
+ $this->drupalGet('admin/config/development/quant/api');
+ $this->assertSession()->checkboxChecked('edit-platform-quantgov');
+ }
+
}
From 8ae8eccf18ced37b973ac034c21f85598a15d421 Mon Sep 17 00:00:00 2001
From: Stuart Rowlands
Date: Mon, 9 Nov 2026 02:10:48 +1000
Subject: [PATCH 12/30] feat(quant): redirect an old alias to the new one when
a page is renamed
Without the Redirect module, a changed alias (for example Pathauto after a
title change) left a 404 at the old URL on the static site. The old alias
now gets a 301 to the new one, for published nodes and terms only. With the
Redirect module on, its own redirect entity is pushed as before.
---
quant.module | 6 +-
src/Seed.php | 42 ++++++-
tests/src/Kernel/AliasChangeRedirectTest.php | 118 +++++++++++++++++++
3 files changed, 163 insertions(+), 3 deletions(-)
create mode 100644 tests/src/Kernel/AliasChangeRedirectTest.php
diff --git a/quant.module b/quant.module
index eed9d5a1..bb7963b3 100644
--- a/quant.module
+++ b/quant.module
@@ -555,7 +555,7 @@ function quant_modules_uninstalled($modules) {
}
/**
- * Unpublish old alias if it changes.
+ * Unpublish an old alias when it changes, and redirect it to the new one.
*
* @param Drupal\Core\Entity\EntityInterface $pathAlias
* The path alias.
@@ -575,9 +575,11 @@ function quant_path_alias_presave(EntityInterface $pathAlias) {
$alias = $pathAlias->get('alias')->value;
$langcode = $pathAlias->get('langcode')->value;
- // Unpublish original alias if it changed.
+ // Unpublish the original alias if it changed, and send a 301 from it to
+ // the new alias so links and search results to the old URL keep working.
if ($original_path == $path && $langcode == $original_langcode && $original_alias != $alias) {
Seed::unpublishPathAlias($pathAlias->original);
+ Seed::redirectChangedAlias($pathAlias->original, $pathAlias);
}
}
diff --git a/src/Seed.php b/src/Seed.php
index 2a98531e..8e96de9d 100644
--- a/src/Seed.php
+++ b/src/Seed.php
@@ -585,7 +585,36 @@ public static function unpublishView(EntityInterface $entity) {
* Unpublish path alias via API request.
*/
public static function unpublishPathAlias($pathAlias) {
+ Utility::unpublishUrl(self::aliasUrl($pathAlias), 'Unpublished path alias');
+ }
+
+ /**
+ * Sends a 301 from an old alias to the new one, so old links keep working.
+ *
+ * Only for published content and only when the Redirect module is off:
+ * with Redirect on, its own redirect entity is pushed instead.
+ *
+ * @param \Drupal\path_alias\PathAliasInterface $original
+ * The alias before the change.
+ * @param \Drupal\path_alias\PathAliasInterface $pathAlias
+ * The alias after the change.
+ */
+ public static function redirectChangedAlias($original, $pathAlias) : void {
+ if (\Drupal::moduleHandler()->moduleExists('redirect') || !self::isPublishedEntityPath((string) $pathAlias->get('path')->value)) {
+ return;
+ }
+ $from = self::aliasUrl($original);
+ $to = self::aliasUrl($pathAlias);
+ if ($from === $to || !trim($to)) {
+ return;
+ }
+ \Drupal::service('event_dispatcher')->dispatch(new QuantRedirectEvent($from, $to, 301), QuantRedirectEvent::UPDATE);
+ }
+ /**
+ * The public URL of a path alias, with the language prefix if any.
+ */
+ protected static function aliasUrl($pathAlias) : string {
$langcode = $pathAlias->get('langcode')->value;
$alias = Utility::getUrl($pathAlias->get('alias')->value, $langcode);
@@ -593,8 +622,19 @@ public static function unpublishPathAlias($pathAlias) {
if ($langcode == LanguageInterface::LANGCODE_NOT_SPECIFIED) {
$alias = str_replace("/{$langcode}", '', $alias);
}
+ return $alias;
+ }
- Utility::unpublishUrl($alias, 'Unpublished path alias');
+ /**
+ * Whether a system path is a published node or taxonomy term.
+ */
+ protected static function isPublishedEntityPath(string $path) : bool {
+ if (!preg_match('#^/(node|taxonomy/term)/(\d+)$#', $path, $m)) {
+ return FALSE;
+ }
+ $type = $m[1] === 'node' ? 'node' : 'taxonomy_term';
+ $entity = \Drupal::entityTypeManager()->getStorage($type)->load($m[2]);
+ return $entity && (!method_exists($entity, 'isPublished') || $entity->isPublished());
}
/**
diff --git a/tests/src/Kernel/AliasChangeRedirectTest.php b/tests/src/Kernel/AliasChangeRedirectTest.php
new file mode 100644
index 00000000..1c15c51f
--- /dev/null
+++ b/tests/src/Kernel/AliasChangeRedirectTest.php
@@ -0,0 +1,118 @@
+installEntitySchema('user');
+ $this->installEntitySchema('node');
+ $this->installEntitySchema('path_alias');
+ $this->installSchema('node', ['node_access']);
+ $this->installConfig(['quant_api']);
+ NodeType::create(['type' => 'page', 'name' => 'Page'])->save();
+
+ $dispatcher = $this->container->get('event_dispatcher');
+ $dispatcher->addListener(QuantRedirectEvent::UPDATE, function (QuantRedirectEvent $event) {
+ $this->events[] = 'redirect ' . $event->getSourceUrl() . ' -> ' . $event->getDestinationUrl() . ' ' . $event->getStatusCode();
+ });
+ $dispatcher->addListener(QuantEvent::UNPUBLISH, function (QuantEvent $event) {
+ $this->events[] = 'unpublish ' . $event->getLocation();
+ });
+ }
+
+ /**
+ * Renaming a published page unpublishes the old URL and redirects it.
+ */
+ public function testRenamedPublishedPageRedirects() : void {
+ $alias = $this->pageWithAlias(TRUE, '/news/old-title');
+ $this->events = [];
+ $alias->setAlias('/news/new-title')->save();
+
+ $this->assertSame([
+ 'unpublish /news/old-title',
+ 'redirect /news/old-title -> /news/new-title 301',
+ ], $this->events);
+ }
+
+ /**
+ * An unpublished page gets no redirect to a URL that is not on the site.
+ */
+ public function testRenamedUnpublishedPageDoesNotRedirect() : void {
+ $alias = $this->pageWithAlias(FALSE, '/news/draft-title');
+ $this->events = [];
+ $alias->setAlias('/news/draft-title-2')->save();
+
+ $this->assertSame(['unpublish /news/draft-title'], $this->events);
+ }
+
+ /**
+ * An alias for a path that is not a node or term gets no redirect.
+ */
+ public function testOtherPathsDoNotRedirect() : void {
+ $alias = PathAlias::create(['path' => '/user/login', 'alias' => '/sign-in', 'langcode' => 'und']);
+ $alias->save();
+ $this->events = [];
+ $alias->setAlias('/log-in')->save();
+
+ $this->assertSame(['unpublish /sign-in'], $this->events);
+ }
+
+ /**
+ * Creates a page and its alias.
+ */
+ protected function pageWithAlias(bool $published, string $path) : PathAlias {
+ $node = Node::create(['type' => 'page', 'title' => 'A page', 'status' => $published]);
+ $node->save();
+ $alias = PathAlias::create(['path' => '/node/' . $node->id(), 'alias' => $path, 'langcode' => 'und']);
+ $alias->save();
+ return $alias;
+ }
+
+}
From 60b108a33692281d89bdf76d3c58692ec8fe232e Mon Sep 17 00:00:00 2001
From: Stuart Rowlands
Date: Mon, 9 Nov 2026 02:21:37 +1000
Subject: [PATCH 13/30] fix(quant): add the config schema for the
quant_metadata Views field
Installing the module failed strict config schema checks for
views.view.quant_metadata_node.
---
config/schema/quant.schema.yml | 11 +++++++++++
1 file changed, 11 insertions(+)
diff --git a/config/schema/quant.schema.yml b/config/schema/quant.schema.yml
index e4a53c40..26294aba 100644
--- a/config/schema/quant.schema.yml
+++ b/config/schema/quant.schema.yml
@@ -114,3 +114,14 @@ quant.token_settings:
strict:
type: boolean
label: 'Reject requests whose token does not match the route'
+
+views.field.quant_metadata:
+ type: views_field
+ label: 'Quant metadata'
+ mapping:
+ quant_metadata:
+ type: sequence
+ label: 'Metadata to show'
+ sequence:
+ type: string
+ label: 'Metadata key'
From 0c993ef44fb92e2432b203f00c62d264b001764b Mon Sep 17 00:00:00 2001
From: Stuart Rowlands
Date: Mon, 9 Nov 2026 02:21:37 +1000
Subject: [PATCH 14/30] fix(quant): unpublish /node/N and /taxonomy/term/N when
content is deleted
A deleted published node or term sent its internal-path redirect again,
because the entity is still published during delete. /node/N kept a 301
to a page that no longer exists. Deleted content now unpublishes its
internal paths (the prefixed one only, for one deleted translation).
---
quant.module | 15 ++-
src/Seed.php | 50 +++++++-
.../src/Kernel/EntityDeleteUnpublishTest.php | 113 ++++++++++++++++++
3 files changed, 166 insertions(+), 12 deletions(-)
create mode 100644 tests/src/Kernel/EntityDeleteUnpublishTest.php
diff --git a/quant.module b/quant.module
index bb7963b3..07ca1c1f 100644
--- a/quant.module
+++ b/quant.module
@@ -226,7 +226,7 @@ function quant_entity_predelete(EntityInterface $entity) {
// This needs to not be a shutdown hook and also needs to be on
// the predelete hook as the path alias is removed in the entity
// delete clean-up.
- _quant_unpublish_entity($entity);
+ _quant_unpublish_entity($entity, TRUE);
// Handle any translations.
if (method_exists($entity, 'isTranslatable') && $entity->isTranslatable() && method_exists($entity, 'getTranslationLanguages')) {
@@ -234,7 +234,7 @@ function quant_entity_predelete(EntityInterface $entity) {
if ($language->getId() != $entity->language()->getId()) {
$translation = $entity->getTranslation($language->getId());
- _quant_unpublish_entity($translation);
+ _quant_unpublish_entity($translation, TRUE);
}
}
}
@@ -401,14 +401,17 @@ function _quant_entity_update_op(EntityInterface $entity) {
*
* @param Drupal\Core\Entity\EntityInterface $entity
* The entity.
+ * @param bool $deleted
+ * TRUE when the entity or translation is being deleted, so its internal
+ * paths are unpublished instead of redirected.
*/
-function _quant_unpublish_entity(EntityInterface $entity) {
+function _quant_unpublish_entity(EntityInterface $entity, bool $deleted = FALSE) {
// @todo Try to handle entities more generically.
// @todo Check tracking is enabled for entity first.
switch ($entity->getEntityTypeId()) {
case 'node':
- Seed::unpublishNode($entity);
+ Seed::unpublishNode($entity, $deleted);
break;
case 'path_alias':
@@ -416,7 +419,7 @@ function _quant_unpublish_entity(EntityInterface $entity) {
break;
case 'taxonomy_term':
- Seed::unpublishTaxonomyTerm($entity);
+ Seed::unpublishTaxonomyTerm($entity, $deleted);
break;
case 'file':
@@ -448,7 +451,7 @@ function quant_entity_translation_delete($entity) {
return;
}
- _quant_unpublish_entity($entity);
+ _quant_unpublish_entity($entity, TRUE);
}
diff --git a/src/Seed.php b/src/Seed.php
index 8e96de9d..5ce7bedb 100644
--- a/src/Seed.php
+++ b/src/Seed.php
@@ -373,8 +373,10 @@ public static function seedNode(EntityInterface $entity, $langcode = NULL) {
*
* @param Drupal\Core\Entity\EntityInterface $entity
* The node entity.
+ * @param bool $deleted
+ * TRUE when the node is being deleted.
*/
- public static function unpublishNode(EntityInterface $entity) {
+ public static function unpublishNode(EntityInterface $entity, bool $deleted = FALSE) {
$langcode = $entity->language()->getId();
$nid = $entity->get('nid')->value;
@@ -386,8 +388,14 @@ public static function unpublishNode(EntityInterface $entity) {
Utility::unpublishUrl('/', 'Unpublished home page');
}
- // Handle internal path redirects.
- self::handleInternalPathRedirects($entity, $langcode, $url);
+ // A deleted node keeps no /node/N redirect. An unpublished one keeps it
+ // while another translation is still published.
+ if ($deleted) {
+ self::unpublishInternalPaths('node', (int) $nid, $langcode, $entity->isDefaultTranslation());
+ }
+ else {
+ self::handleInternalPathRedirects($entity, $langcode, $url);
+ }
Utility::unpublishUrl($url, 'Unpublished content page');
}
@@ -397,15 +405,21 @@ public static function unpublishNode(EntityInterface $entity) {
*
* @param Drupal\Core\Entity\EntityInterface $entity
* The term entity.
+ * @param bool $deleted
+ * TRUE when the term is being deleted.
*/
- public static function unpublishTaxonomyTerm(EntityInterface $entity) {
+ public static function unpublishTaxonomyTerm(EntityInterface $entity, bool $deleted = FALSE) {
$langcode = $entity->language()->getId();
$tid = $entity->get('tid')->value;
$url = Utility::getCanonicalUrl('taxonomy_term', $tid, $langcode);
- // Handle internal path redirects.
- self::handleInternalPathRedirects($entity, $langcode, $url);
+ if ($deleted) {
+ self::unpublishInternalPaths('taxonomy_term', (int) $tid, $langcode, $entity->isDefaultTranslation());
+ }
+ else {
+ self::handleInternalPathRedirects($entity, $langcode, $url);
+ }
Utility::unpublishUrl($url, 'Unpublished taxonomy term page');
}
@@ -637,6 +651,30 @@ protected static function isPublishedEntityPath(string $path) : bool {
return $entity && (!method_exists($entity, 'isPublished') || $entity->isPublished());
}
+ /**
+ * Unpublishes the internal paths of deleted content.
+ *
+ * @param string $type
+ * The entity type: node or taxonomy_term.
+ * @param int $id
+ * The entity ID.
+ * @param string $langcode
+ * The language of the deleted translation.
+ * @param bool $whole
+ * TRUE when the whole entity is deleted, so the unprefixed internal path
+ * goes too; FALSE for one translation, which loses only its prefixed path.
+ */
+ protected static function unpublishInternalPaths(string $type, int $id, string $langcode, bool $whole) : void {
+ $internalPath = ($type == 'node') ? "/node/{$id}" : "/taxonomy/term/{$id}";
+ if ($whole) {
+ Utility::unpublishUrl($internalPath, 'Unpublished internal path');
+ }
+ $prefix = Utility::getPathPrefix($langcode);
+ if ($prefix !== '' && $prefix !== '/') {
+ Utility::unpublishUrl("{$prefix}{$internalPath}", 'Unpublished internal path');
+ }
+ }
+
/**
* Handle internal path redirects.
*
diff --git a/tests/src/Kernel/EntityDeleteUnpublishTest.php b/tests/src/Kernel/EntityDeleteUnpublishTest.php
new file mode 100644
index 00000000..43f7d690
--- /dev/null
+++ b/tests/src/Kernel/EntityDeleteUnpublishTest.php
@@ -0,0 +1,113 @@
+installEntitySchema('user');
+ $this->installEntitySchema('node');
+ $this->installEntitySchema('taxonomy_term');
+ $this->installEntitySchema('path_alias');
+ $this->installSchema('node', ['node_access']);
+ $this->installConfig(['quant', 'quant_api']);
+ // Delete handling needs only quant_enabled. Push on save stays off, so
+ // creating the test content queues no shutdown push.
+ $this->config('quant.settings')
+ ->set('quant_enabled', TRUE)
+ ->set('quant_enabled_nodes', FALSE)
+ ->set('quant_enabled_taxonomy', FALSE)
+ ->save();
+ NodeType::create(['type' => 'page', 'name' => 'Page'])->save();
+ Vocabulary::create(['vid' => 'topic', 'name' => 'Topic'])->save();
+
+ $dispatcher = $this->container->get('event_dispatcher');
+ $dispatcher->addListener(QuantRedirectEvent::UPDATE, function (QuantRedirectEvent $event) {
+ $this->events[] = 'redirect ' . $event->getSourceUrl() . ' -> ' . $event->getDestinationUrl();
+ });
+ $dispatcher->addListener(QuantEvent::UNPUBLISH, function (QuantEvent $event) {
+ $this->events[] = 'unpublish ' . $event->getLocation();
+ });
+ }
+
+ /**
+ * A deleted published node loses its page and its /node/N redirect.
+ */
+ public function testDeletedNodeUnpublishesInternalPath() : void {
+ $node = Node::create(['type' => 'page', 'title' => 'Temporary', 'status' => 1]);
+ $node->save();
+ PathAlias::create(['path' => '/node/' . $node->id(), 'alias' => '/temporary', 'langcode' => 'und'])->save();
+ $this->events = [];
+
+ $node->delete();
+
+ $this->assertContains('unpublish /temporary', $this->events);
+ $this->assertContains('unpublish /node/' . $node->id(), $this->events);
+ $this->assertEmpty(preg_grep('/^redirect /', $this->events), 'No redirect is sent for deleted content.');
+ }
+
+ /**
+ * A deleted published term loses its page and its /taxonomy/term/N path.
+ */
+ public function testDeletedTermUnpublishesInternalPath() : void {
+ $term = Term::create(['vid' => 'topic', 'name' => 'Drought', 'status' => 1]);
+ $term->save();
+ PathAlias::create(['path' => '/taxonomy/term/' . $term->id(), 'alias' => '/topics/drought', 'langcode' => 'und'])->save();
+ $this->events = [];
+
+ $term->delete();
+
+ $this->assertContains('unpublish /topics/drought', $this->events);
+ $this->assertContains('unpublish /taxonomy/term/' . $term->id(), $this->events);
+ $this->assertEmpty(preg_grep('/^redirect /', $this->events), 'No redirect is sent for deleted content.');
+ }
+
+}
From ed0d605732d51ef2ff9f9f41f3e7f4e402a7b6b3 Mon Sep 17 00:00:00 2001
From: Stuart Rowlands
Date: Mon, 9 Nov 2026 08:59:26 +1000
Subject: [PATCH 15/30] fix(quant): remove Views UI options from the file
metadata view
The file_link formatter settings of views.view.quant_metadata_file carried
a copy of Views UI options, which fails strict config schema checks.
---
.../views.view.quant_metadata_file.yml | 57 +------------------
1 file changed, 1 insertion(+), 56 deletions(-)
diff --git a/config/install/views.view.quant_metadata_file.yml b/config/install/views.view.quant_metadata_file.yml
index 482b6944..d32ccffb 100644
--- a/config/install/views.view.quant_metadata_file.yml
+++ b/config/install/views.view.quant_metadata_file.yml
@@ -75,62 +75,7 @@ display:
hide_alter_empty: true
click_sort_column: value
type: file_link
- settings:
- relationship: none
- fieldsets:
- - more
- - admin_label
- custom_label: 0
- label: ''
- element_label_colon: 1
- exclude: 0
- element_type_enable: 0
- element_type: ''
- element_class_enable: 0
- element_class: ''
- element_label_type_enable: 0
- element_label_type: ''
- element_label_class_enable: 0
- element_label_class: ''
- element_wrapper_type_enable: 0
- element_wrapper_type: ''
- element_wrapper_class_enable: 0
- element_wrapper_class: ''
- element_default_classes: 1
- alter:
- alter_text: 0
- text: ''
- make_link: 0
- path: ''
- absolute: 0
- replace_spaces: 0
- external: 0
- path_case: none
- link_class: ''
- alt: ''
- rel: ''
- prefix: ''
- suffix: ''
- target: ''
- trim: 0
- max_length: '0'
- word_boundary: 0
- ellipsis: 0
- more_link: 0
- more_link_text: ''
- more_link_path: ''
- html: 0
- strip_tags: 0
- preserve_tags: ''
- trim_whitespace: 0
- nl2br: 0
- empty: ''
- empty_zero: 0
- hide_empty: 0
- hide_alter_empty: 1
- click_sort_column: value
- type: file_link
- field_api_classes: 0
+ settings: { }
group_column: value
group_columns: { }
group_rows: true
From 7f91d3a452e40b83cf2e25992e0caf72989249a1 Mon Sep 17 00:00:00 2001
From: Stuart Rowlands
Date: Mon, 9 Nov 2026 03:10:13 +1000
Subject: [PATCH 16/30] sec(quant_api): end the login in the dashboard on Log
out of Quant
Log out deleted the token only in Drupal, so it stayed valid on the portal
until it expired. The module now calls POST /api/oauth/revoke first (best
effort: an older dashboard or a failed call is logged, and the local
connection is deleted in every case).
---
modules/quant_api/src/OAuth/AuthService.php | 33 ++++++++++++++++++-
.../src/FakeQuantMiddleware.php | 1 +
.../tests/src/Functional/OAuthConnectTest.php | 3 ++
3 files changed, 36 insertions(+), 1 deletion(-)
diff --git a/modules/quant_api/src/OAuth/AuthService.php b/modules/quant_api/src/OAuth/AuthService.php
index d7df0e5a..a8d78a3d 100644
--- a/modules/quant_api/src/OAuth/AuthService.php
+++ b/modules/quant_api/src/OAuth/AuthService.php
@@ -206,12 +206,43 @@ public function saveConnection(array $tokens, string $write_token, array $user):
}
/**
- * Deletes the local connection. The portal has no revoke endpoint yet.
+ * Ends the login in the dashboard, then deletes the local connection.
+ *
+ * The dashboard call is best effort: an older dashboard without the revoke
+ * endpoint, or one that cannot be reached, leaves the token to expire on
+ * its own. The local connection is deleted in every case.
*/
public function disconnect(): void {
+ $access = $this->tokenStore->get(OAuthTokenStore::ACCESS_TOKEN);
+ if (is_string($access) && $access !== '') {
+ $this->revokeAtDashboard($access);
+ }
$this->tokenStore->clear();
}
+ /**
+ * Asks the dashboard to delete this site's token (POST /api/oauth/revoke).
+ */
+ protected function revokeAtDashboard(string $access_token): void {
+ try {
+ $response = $this->httpClient->request('POST', $this->getInternalDashboardUrl() . '/api/oauth/revoke', [
+ 'headers' => [
+ 'Authorization' => 'Bearer ' . $access_token,
+ 'Accept' => 'application/json',
+ ],
+ 'timeout' => 10,
+ 'http_errors' => FALSE,
+ ]);
+ $status = $response->getStatusCode();
+ }
+ catch (\Exception $e) {
+ $status = get_class($e);
+ }
+ if ($status !== 200) {
+ $this->logger->notice('The Quant dashboard did not end the login (@status). The token stops working when it expires.', ['@status' => $status]);
+ }
+ }
+
/**
* The access token, refreshed when it is about to expire.
*
diff --git a/modules/quant_api/tests/modules/quant_api_oauth_test/src/FakeQuantMiddleware.php b/modules/quant_api/tests/modules/quant_api_oauth_test/src/FakeQuantMiddleware.php
index 2594f546..3c7ad0f8 100644
--- a/modules/quant_api/tests/modules/quant_api_oauth_test/src/FakeQuantMiddleware.php
+++ b/modules/quant_api/tests/modules/quant_api_oauth_test/src/FakeQuantMiddleware.php
@@ -70,6 +70,7 @@ protected function dashboard(RequestInterface $request, string $path): Response
'email' => 'editor@example.com',
'organizations' => [['id' => 3, 'machine_name' => 'test-org', 'name' => 'Test Org', 'roles' => []]],
])),
+ $method === 'POST' && $path === '/api/oauth/revoke' => $this->bearer($request, self::json(200, ['revoked' => TRUE])),
$method === 'POST' && $path === '/api/oauth/token/project' => $this->bearer($request, $this->limit($request)),
$method === 'GET' && preg_match('#^/api/v2/organizations/test-org/projects/([a-z-]+)$#', $path, $m) === 1 => $this->bearer($request, $this->project($m[1])),
default => self::json(404, ['message' => 'Not found']),
diff --git a/modules/quant_api/tests/src/Functional/OAuthConnectTest.php b/modules/quant_api/tests/src/Functional/OAuthConnectTest.php
index d101f860..abf9f8cc 100644
--- a/modules/quant_api/tests/src/Functional/OAuthConnectTest.php
+++ b/modules/quant_api/tests/src/Functional/OAuthConnectTest.php
@@ -103,8 +103,11 @@ public function testLoginChooseChangeLogout(): void {
$session->pageTextContains('Connected to the Quant project site-a.');
$this->assertSame('fake-write-token-site-a', $this->store()->get(OAuthTokenStore::WRITE_TOKEN));
+ \Drupal::state()->delete('quant_api_oauth_test.requests');
$this->clickLink('Log out of Quant');
$session->pageTextContains('This site is logged out of Quant.');
+ // The login is ended in the dashboard too, not only deleted here.
+ $this->assertContains('POST /quant-test-dashboard/api/oauth/revoke', \Drupal::state()->get('quant_api_oauth_test.requests', []));
$this->assertSame('token', $this->config('quant_api.settings')->get('auth_method'));
$this->assertSame([], array_filter($this->store()->getAll()));
$session->buttonExists('Log in to Quant');
From 8128c4b649c54c90eee56241ac5bcbbcfadfac7a Mon Sep 17 00:00:00 2001
From: Stuart Rowlands
Date: Mon, 9 Nov 2026 08:46:47 +1000
Subject: [PATCH 17/30] fix(quant_api): tell the editor when the Quant login
needs renewing
After Quant refused a refresh (for example a revoked login), the settings
page still showed "Connected" and the reconnect note sat below it. The
page message is now the warning, and the status report shows "The Quant
login needs renewing". After a project pick the page shows one success
message, not two. The status is built on a page view only: built during a
submit, it showed stale after the redirect (for example the copied-site
error right after "This is the same site").
---
modules/quant_api/quant_api.install | 18 +++++++++++++-
modules/quant_api/src/Form/SettingsForm.php | 21 +++++++++++-----
.../tests/src/Functional/OAuthConnectTest.php | 24 ++++++++++++++++---
3 files changed, 53 insertions(+), 10 deletions(-)
diff --git a/modules/quant_api/quant_api.install b/modules/quant_api/quant_api.install
index 9746fd0e..9aa261ea 100644
--- a/modules/quant_api/quant_api.install
+++ b/modules/quant_api/quant_api.install
@@ -16,9 +16,25 @@ function quant_api_requirements($phase) {
return [];
}
$credentials = \Drupal::service('quant_api.credentials');
- if (!$credentials->usesOauth() || !$credentials->isCopiedSite()) {
+ if (!$credentials->usesOauth()) {
return [];
}
+ if (!$credentials->isCopiedSite()) {
+ $reason = \Drupal::service('quant_api.oauth')->getReconnectReason();
+ if ($reason === NULL) {
+ return [];
+ }
+ return [
+ 'quant_api_login' => [
+ 'title' => t('Quant'),
+ 'value' => t('The Quant login needs renewing'),
+ 'description' => t('Content still publishes. Log in to Quant again on the Quant API settings page to keep the connection current.', [
+ ':url' => Url::fromRoute('quant_api.settings_form')->toString(),
+ ]),
+ 'severity' => REQUIREMENT_WARNING,
+ ],
+ ];
+ }
return [
'quant_api_copied_site' => [
'title' => t('Quant'),
diff --git a/modules/quant_api/src/Form/SettingsForm.php b/modules/quant_api/src/Form/SettingsForm.php
index 5312fcaf..66cbfdbc 100644
--- a/modules/quant_api/src/Form/SettingsForm.php
+++ b/modules/quant_api/src/Form/SettingsForm.php
@@ -86,7 +86,11 @@ public function getEditableConfigNames() {
*/
public function buildForm(array $form, FormStateInterface $form_state) {
$config = $this->config(self::SETTINGS);
- $this->showStatus();
+ // Only on a page view: a build during a submit runs before the submit
+ // handler, so its message would show stale after the redirect.
+ if ($this->getRequest()->isMethod('GET')) {
+ $this->showStatus();
+ }
$form['platform'] = [
'#type' => 'radios',
@@ -150,12 +154,8 @@ protected function buildConnected($config): array {
'%org' => $config->get('api_account'),
]) . '
',
];
+ // A connection that needs a new login is shown as the page message.
$reason = $this->auth->getReconnectReason();
- if ($reason !== NULL) {
- $items['reconnect'] = [
- '#markup' => '' . $this->reconnectMessage($reason) . '
',
- ];
- }
if ($reason === AuthService::RECONNECT_OTHER_SITE) {
$items['same_site'] = [
'#type' => 'submit',
@@ -275,7 +275,16 @@ protected function showStatus(): void {
if (!$this->credentials->hasToken()) {
return;
}
+ $reason = $this->credentials->usesOauth() ? $this->auth->getReconnectReason() : NULL;
+ if ($reason !== NULL) {
+ $this->messenger()->addWarning($this->reconnectMessage($reason));
+ return;
+ }
if ($this->client->ping()) {
+ // After a project pick, the pick already said where the site publishes.
+ if ($this->messenger()->messagesByType('status') !== []) {
+ return;
+ }
$this->messenger()->addStatus($this->t('Connected to the Quant project %project.', ['%project' => $this->client->getProject()]));
return;
}
diff --git a/modules/quant_api/tests/src/Functional/OAuthConnectTest.php b/modules/quant_api/tests/src/Functional/OAuthConnectTest.php
index abf9f8cc..51aa6736 100644
--- a/modules/quant_api/tests/src/Functional/OAuthConnectTest.php
+++ b/modules/quant_api/tests/src/Functional/OAuthConnectTest.php
@@ -78,7 +78,8 @@ public function testLoginChooseChangeLogout(): void {
$session = $this->assertSession();
$session->pageTextContains('This site now publishes to the Quant project site-b.');
- $session->pageTextContains('Connected to the Quant project site-b.');
+ // One success message, not the same news twice.
+ $session->pageTextNotContains('Connected to the Quant project site-b.');
$session->pageTextContains('it can reach only that project');
$session->linkExists('Change project');
@@ -100,7 +101,7 @@ public function testLoginChooseChangeLogout(): void {
$this->clickLink('Change project');
$this->assertSame('site-b', $this->config('quant_api.settings')->get('api_project'));
$this->submitForm(['project' => 'test-org:site-a'], 'Use this project');
- $session->pageTextContains('Connected to the Quant project site-a.');
+ $session->pageTextContains('This site now publishes to the Quant project site-a.');
$this->assertSame('fake-write-token-site-a', $this->store()->get(OAuthTokenStore::WRITE_TOKEN));
\Drupal::state()->delete('quant_api_oauth_test.requests');
@@ -225,6 +226,23 @@ public function testCompletedLoginSavesThePlatform(): void {
$this->assertSession()->elementAttributeExists('css', 'input[name="api_endpoint"]', 'disabled');
}
+ /**
+ * A refused refresh is not shown as "Connected", and the status report says so.
+ */
+ public function testRefusedLoginIsShownToTheEditor(): void {
+ $this->logInAndPick('site-b');
+ \Drupal::service('quant_api.oauth_store')->set(OAuthTokenStore::REFUSAL, ['error' => 'invalid_grant', 'at' => time()]);
+
+ $this->drupalGet('admin/config/development/quant/api');
+ $session = $this->assertSession();
+ $session->pageTextNotContains('Connected to the Quant project site-b.');
+ $session->elementTextContains('css', '[role="contentinfo"], .messages--warning, [data-drupal-messages]', 'Log in to Quant again');
+
+ $this->drupalLogin($this->drupalCreateUser(['configure quant', 'administer site configuration']));
+ $this->drupalGet('admin/reports/status');
+ $session->pageTextContains('The Quant login needs renewing');
+ }
+
/**
* A copied site says so and can be confirmed as the same site.
*/
@@ -239,7 +257,7 @@ public function testCopiedSiteCanBeConfirmed(): void {
$this->assertSession()->pageTextContains('This site looks like a copy of the connected site (different: database)');
$this->submitForm([], 'This is the same site');
$this->assertSession()->pageTextContains('Confirmed. This site publishes with its Quant login again.');
- $this->assertSession()->pageTextContains('Connected to the Quant project site-b.');
+ $this->assertSession()->pageTextNotContains('This site looks like a copy');
}
/**
From 307b9255ac88e404dc92cfac63a4ab4f282be6e5 Mon Sep 17 00:00:00 2001
From: Stuart Rowlands
Date: Mon, 9 Nov 2026 08:51:22 +1000
Subject: [PATCH 18/30] fix(quant_api): name the button "Log in to Quant again"
when the login needs renewing
The warning asked the editor to log in again, but the only button was
"Change project".
---
modules/quant_api/src/Form/SettingsForm.php | 2 +-
modules/quant_api/tests/src/Functional/OAuthConnectTest.php | 3 +++
2 files changed, 4 insertions(+), 1 deletion(-)
diff --git a/modules/quant_api/src/Form/SettingsForm.php b/modules/quant_api/src/Form/SettingsForm.php
index 66cbfdbc..987b2ea8 100644
--- a/modules/quant_api/src/Form/SettingsForm.php
+++ b/modules/quant_api/src/Form/SettingsForm.php
@@ -166,7 +166,7 @@ protected function buildConnected($config): array {
}
$items['change'] = [
'#type' => 'link',
- '#title' => $this->t('Change project'),
+ '#title' => $reason === NULL ? $this->t('Change project') : $this->t('Log in to Quant again'),
'#url' => Url::fromRoute('quant_api.oauth_connect'),
'#attributes' => ['class' => ['button', 'button--primary']],
];
diff --git a/modules/quant_api/tests/src/Functional/OAuthConnectTest.php b/modules/quant_api/tests/src/Functional/OAuthConnectTest.php
index 51aa6736..1f96ec70 100644
--- a/modules/quant_api/tests/src/Functional/OAuthConnectTest.php
+++ b/modules/quant_api/tests/src/Functional/OAuthConnectTest.php
@@ -237,6 +237,9 @@ public function testRefusedLoginIsShownToTheEditor(): void {
$session = $this->assertSession();
$session->pageTextNotContains('Connected to the Quant project site-b.');
$session->elementTextContains('css', '[role="contentinfo"], .messages--warning, [data-drupal-messages]', 'Log in to Quant again');
+ // The button says what the message asks for.
+ $session->linkExists('Log in to Quant again');
+ $session->linkNotExists('Change project');
$this->drupalLogin($this->drupalCreateUser(['configure quant', 'administer site configuration']));
$this->drupalGet('admin/reports/status');
From 9307c8ac6e4d893b62e986a64d8e6cb540262cfc Mon Sep 17 00:00:00 2001
From: Stuart Rowlands
Date: Mon, 9 Nov 2026 08:58:39 +1000
Subject: [PATCH 19/30] fix(quant_api): an unreachable Quant endpoint no longer
crashes the settings page
Guzzle's ConnectException is not a RequestException, so ping(), project()
and search() let it through and the Quant API settings page failed with an
error page. They now catch every Guzzle error. The editor sees a fixed
message instead of the exception text, which names the endpoint URL and can
carry credentials; the log keeps the cause with URL user information
removed. The settings page shows one error, not two.
---
modules/quant_api/src/Client/QuantClient.php | 29 +++++++++----
modules/quant_api/src/Form/SettingsForm.php | 5 ++-
.../tests/src/Unit/QuantClientTest.php | 42 ++++++++++++++++++-
3 files changed, 67 insertions(+), 9 deletions(-)
diff --git a/modules/quant_api/src/Client/QuantClient.php b/modules/quant_api/src/Client/QuantClient.php
index 988b36d2..b41ac84e 100644
--- a/modules/quant_api/src/Client/QuantClient.php
+++ b/modules/quant_api/src/Client/QuantClient.php
@@ -8,7 +8,7 @@
use Drupal\quant_api\Credentials;
use Drupal\quant_api\Exception\InvalidPayload;
use GuzzleHttp\Client;
-use GuzzleHttp\Exception\RequestException;
+use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\MultipartStream;
use GuzzleHttp\Psr7\Utils;
@@ -101,6 +101,21 @@ public function __construct(Client $client, ConfigFactoryInterface $config_facto
$this->refreshCredentials();
}
+ /**
+ * Tells the editor that Quant could not be reached, and logs the cause.
+ *
+ * The exception text names the request URL, which can carry credentials,
+ * so the editor sees a fixed message and the log gets the class and the
+ * text without any user information in URLs.
+ */
+ protected function reportUnreachable(GuzzleException $e) : void {
+ $this->logger->error('Quant API request failed (@class): @message', [
+ '@class' => get_class($e),
+ '@message' => preg_replace('#(https?://)[^/\s@]+@#i', '$1', $e->getMessage()),
+ ]);
+ \Drupal::messenger()->addError(t('This site could not reach Quant. Check the API endpoint on the Quant API settings page, or try again in a few minutes. After Quant is back, save the changed content again or run a seed.'));
+ }
+
/**
* Re-reads the API credentials from configuration.
*
@@ -245,8 +260,8 @@ public function ping() {
'verify' => $this->tlsDisabled ? FALSE : TRUE,
]);
}
- catch (RequestException $e) {
- \Drupal::messenger()->addError($e->getMessage());
+ catch (GuzzleException $e) {
+ $this->reportUnreachable($e);
return FALSE;
}
@@ -288,8 +303,8 @@ public function project() {
'exceptions' => FALSE,
]);
}
- catch (RequestException $e) {
- \Drupal::messenger()->addError($e->getMessage());
+ catch (GuzzleException $e) {
+ $this->reportUnreachable($e);
return FALSE;
}
@@ -331,8 +346,8 @@ public function search() {
'exceptions' => FALSE,
]);
}
- catch (RequestException $e) {
- \Drupal::messenger()->addError($e->getMessage());
+ catch (GuzzleException $e) {
+ $this->reportUnreachable($e);
return FALSE;
}
diff --git a/modules/quant_api/src/Form/SettingsForm.php b/modules/quant_api/src/Form/SettingsForm.php
index 987b2ea8..8dc3a4b4 100644
--- a/modules/quant_api/src/Form/SettingsForm.php
+++ b/modules/quant_api/src/Form/SettingsForm.php
@@ -288,7 +288,10 @@ protected function showStatus(): void {
$this->messenger()->addStatus($this->t('Connected to the Quant project %project.', ['%project' => $this->client->getProject()]));
return;
}
- $this->messenger()->addError($this->t('The Quant API did not accept the connection. Check the platform and the project, or log in to Quant again.'));
+ // The client has already said why when Quant could not be reached.
+ if ($this->messenger()->messagesByType('error') === []) {
+ $this->messenger()->addError($this->t('The Quant API did not accept the connection. Check the platform and the project, or log in to Quant again.'));
+ }
}
/**
diff --git a/modules/quant_api/tests/src/Unit/QuantClientTest.php b/modules/quant_api/tests/src/Unit/QuantClientTest.php
index cc8af5b2..bda8a926 100644
--- a/modules/quant_api/tests/src/Unit/QuantClientTest.php
+++ b/modules/quant_api/tests/src/Unit/QuantClientTest.php
@@ -12,6 +12,7 @@
use Drupal\quant_api\Exception\InvalidPayload;
use Drupal\Tests\UnitTestCase;
use GuzzleHttp\Client;
+use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
@@ -105,10 +106,19 @@ protected function client(array $responses, array $overrides = []) : QuantClient
return new QuantClient(
new Client(['handler' => $stack]),
$this->configFactory($overrides),
- $this->createMock(LoggerChannelFactoryInterface::class)
+ $this->loggerFactory()
);
}
+ /**
+ * A logger factory whose channel accepts every call.
+ */
+ protected function loggerFactory() : LoggerChannelFactoryInterface {
+ $factory = $this->createMock(LoggerChannelFactoryInterface::class);
+ $factory->method('get')->willReturn($this->createMock(LoggerChannelInterface::class));
+ return $factory;
+ }
+
/**
* Builds a config factory returning the test credentials.
*
@@ -522,4 +532,34 @@ public function testNoTokenMeansNoRequest() : void {
$this->assertCount(0, $this->history, 'No request was sent.');
}
+
+ /**
+ * An unreachable endpoint is a plain message, not a crash or a URL leak.
+ *
+ * Guzzle's ConnectException is not a RequestException, so a catch of the
+ * latter let it through and the settings page failed with an error page.
+ * The exception text carries the endpoint URL, which can hold credentials.
+ */
+ public function testUnreachableEndpointIsReportedPlainly() : void {
+ $messenger = $this->createMock(MessengerInterface::class);
+ $shown = [];
+ $messenger->method('addError')->willReturnCallback(function ($message) use (&$shown, $messenger) {
+ $shown[] = (string) $message;
+ return $messenger;
+ });
+ \Drupal::getContainer()->set('messenger', $messenger);
+
+ $down = fn () => new ConnectException('cURL error 7: Failed to connect for https://user:secret@uploads.example/ping', new Request('GET', 'https://user:secret@uploads.example/ping'));
+ $client = $this->client([$down(), $down(), $down()]);
+
+ $this->assertFalse($client->ping());
+ $this->assertFalse($client->project());
+ $this->assertFalse($client->search());
+ $this->assertNotEmpty($shown);
+ foreach ($shown as $message) {
+ $this->assertStringContainsString('could not reach Quant', $message);
+ $this->assertStringNotContainsString('secret', $message);
+ }
+ }
+
}
From d70d2430d2b222be4ea802059f496ba31fa8d8e1 Mon Sep 17 00:00:00 2001
From: Stuart Rowlands
Date: Mon, 9 Nov 2026 09:15:58 +1000
Subject: [PATCH 20/30] style(quant_api): coding standards in the new tests
---
modules/quant_api/tests/src/Functional/OAuthConnectTest.php | 5 ++++-
modules/quant_api/tests/src/Unit/QuantClientTest.php | 1 -
2 files changed, 4 insertions(+), 2 deletions(-)
diff --git a/modules/quant_api/tests/src/Functional/OAuthConnectTest.php b/modules/quant_api/tests/src/Functional/OAuthConnectTest.php
index 1f96ec70..2c689ab1 100644
--- a/modules/quant_api/tests/src/Functional/OAuthConnectTest.php
+++ b/modules/quant_api/tests/src/Functional/OAuthConnectTest.php
@@ -231,7 +231,10 @@ public function testCompletedLoginSavesThePlatform(): void {
*/
public function testRefusedLoginIsShownToTheEditor(): void {
$this->logInAndPick('site-b');
- \Drupal::service('quant_api.oauth_store')->set(OAuthTokenStore::REFUSAL, ['error' => 'invalid_grant', 'at' => time()]);
+ \Drupal::service('quant_api.oauth_store')->set(OAuthTokenStore::REFUSAL, [
+ 'error' => 'invalid_grant',
+ 'at' => time(),
+ ]);
$this->drupalGet('admin/config/development/quant/api');
$session = $this->assertSession();
diff --git a/modules/quant_api/tests/src/Unit/QuantClientTest.php b/modules/quant_api/tests/src/Unit/QuantClientTest.php
index bda8a926..0d9f1f45 100644
--- a/modules/quant_api/tests/src/Unit/QuantClientTest.php
+++ b/modules/quant_api/tests/src/Unit/QuantClientTest.php
@@ -532,7 +532,6 @@ public function testNoTokenMeansNoRequest() : void {
$this->assertCount(0, $this->history, 'No request was sent.');
}
-
/**
* An unreachable endpoint is a plain message, not a crash or a URL leak.
*
From 04c880c1c63423dccf7c29f9fe3ba921781f40b6 Mon Sep 17 00:00:00 2001
From: Stuart Rowlands
Date: Mon, 9 Nov 2026 09:16:43 +1000
Subject: [PATCH 21/30] test: run the tests with strict config schema checks
again
The core schema fixes from fix/content-paths make the shipped views valid.
---
.../quant_api/tests/src/Functional/OAuthConnectTest.php | 7 -------
1 file changed, 7 deletions(-)
diff --git a/modules/quant_api/tests/src/Functional/OAuthConnectTest.php b/modules/quant_api/tests/src/Functional/OAuthConnectTest.php
index 2c689ab1..016e19f4 100644
--- a/modules/quant_api/tests/src/Functional/OAuthConnectTest.php
+++ b/modules/quant_api/tests/src/Functional/OAuthConnectTest.php
@@ -24,13 +24,6 @@ class OAuthConnectTest extends BrowserTestBase {
*/
protected $defaultTheme = 'stark';
- /**
- * The quant module's shipped metadata views have schema gaps (before 2.3).
- *
- * @var bool
- */
- protected $strictConfigSchema = FALSE;
-
/**
* {@inheritdoc}
*/
From 4f7454b429491a9ddc66f142e449905da990d4c4 Mon Sep 17 00:00:00 2001
From: Stuart Rowlands
Date: Mon, 9 Nov 2026 09:32:48 +1000
Subject: [PATCH 22/30] sec(quant_api): never revoke another site's login; end
replaced logins; review fixes
From the release review:
- Log out (or a token save) on a copy of another site's database revoked the
original site's login. A copy now only forgets the login locally.
- Change project and a failed project pick now end the login they replace
or discard, instead of leaving it valid at the dashboard.
- An expired access token with a refresh token stored no longer shows
"The Quant login needs renewing": the next request renews it.
- The settings page decides on its success and error messages from a session
flag and the client's own unreachable flag, not from other queued
messages, so a token save shows "Connected" again.
- The endpoint override warning no longer shows credentials in the URL.
---
modules/quant_api/src/Client/QuantClient.php | 16 +++++
.../src/Client/QuantClientInterface.php | 10 ++++
.../quant_api/src/Form/ProjectSelectForm.php | 3 +
modules/quant_api/src/Form/SettingsForm.php | 15 ++++-
modules/quant_api/src/OAuth/AuthService.php | 33 ++++++++---
.../tests/src/Kernel/OAuthAuthServiceTest.php | 59 +++++++++++++++++++
.../tests/src/Unit/QuantClientTest.php | 1 +
7 files changed, 127 insertions(+), 10 deletions(-)
diff --git a/modules/quant_api/src/Client/QuantClient.php b/modules/quant_api/src/Client/QuantClient.php
index b41ac84e..bbf6ce14 100644
--- a/modules/quant_api/src/Client/QuantClient.php
+++ b/modules/quant_api/src/Client/QuantClient.php
@@ -19,6 +19,13 @@
*/
class QuantClient implements QuantClientInterface {
+ /**
+ * Whether the last ping() failed because Quant could not be reached.
+ *
+ * @var bool
+ */
+ protected bool $unreachable = FALSE;
+
/**
* The configuration object for Quant API.
*
@@ -101,6 +108,13 @@ public function __construct(Client $client, ConfigFactoryInterface $config_facto
$this->refreshCredentials();
}
+ /**
+ * {@inheritdoc}
+ */
+ public function lastCallUnreachable() : bool {
+ return $this->unreachable;
+ }
+
/**
* Tells the editor that Quant could not be reached, and logs the cause.
*
@@ -109,6 +123,7 @@ public function __construct(Client $client, ConfigFactoryInterface $config_facto
* text without any user information in URLs.
*/
protected function reportUnreachable(GuzzleException $e) : void {
+ $this->unreachable = TRUE;
$this->logger->error('Quant API request failed (@class): @message', [
'@class' => get_class($e),
'@message' => preg_replace('#(https?://)[^/\s@]+@#i', '$1', $e->getMessage()),
@@ -242,6 +257,7 @@ public function getOverrides() {
* {@inheritdoc}
*/
public function ping() {
+ $this->unreachable = FALSE;
$this->refreshCredentials();
if ($this->refusesWithoutToken('to check the connection')) {
return FALSE;
diff --git a/modules/quant_api/src/Client/QuantClientInterface.php b/modules/quant_api/src/Client/QuantClientInterface.php
index ae518487..90582523 100644
--- a/modules/quant_api/src/Client/QuantClientInterface.php
+++ b/modules/quant_api/src/Client/QuantClientInterface.php
@@ -111,6 +111,16 @@ public function unpublish(string $url) : array;
*/
public function sendSearchRecords(array $records) : array;
+ /**
+ * Whether the last ping() failed because Quant could not be reached.
+ *
+ * The client has then already shown the editor why.
+ *
+ * @return bool
+ * TRUE when the endpoint could not be reached.
+ */
+ public function lastCallUnreachable() : bool;
+
/**
* Clear a search index.
*
diff --git a/modules/quant_api/src/Form/ProjectSelectForm.php b/modules/quant_api/src/Form/ProjectSelectForm.php
index 9768d5eb..5bc72afe 100644
--- a/modules/quant_api/src/Form/ProjectSelectForm.php
+++ b/modules/quant_api/src/Form/ProjectSelectForm.php
@@ -112,6 +112,8 @@ public function submitForm(array &$form, FormStateInterface $form_state) {
// A limited login cannot choose another project, so it is never reused.
$this->tokenStore->deletePendingLogin($uid);
if ($write_token === NULL) {
+ // The new login is not kept, so it is ended at the dashboard too.
+ $this->auth->revokeToken($access);
$this->messenger()->addError($this->t('Quant did not give this site access to the project. Log in again, or connect with a project token.'));
$form_state->setRedirect('quant_api.settings_form');
return;
@@ -129,6 +131,7 @@ public function submitForm(array &$form, FormStateInterface $form_state) {
\Drupal::service('router.builder')->setRebuildNeeded();
$this->messenger()->addStatus($this->t('This site now publishes to the Quant project %project.', ['%project' => $project]));
+ $this->getRequest()->getSession()->set(SettingsForm::REPORTED, TRUE);
$form_state->setRedirect('quant_api.settings_form');
}
diff --git a/modules/quant_api/src/Form/SettingsForm.php b/modules/quant_api/src/Form/SettingsForm.php
index 8dc3a4b4..81b12cf8 100644
--- a/modules/quant_api/src/Form/SettingsForm.php
+++ b/modules/quant_api/src/Form/SettingsForm.php
@@ -25,6 +25,11 @@
*/
class SettingsForm extends ConfigFormBase {
+ /**
+ * Session flag: the connection was just reported, so no second message.
+ */
+ public const REPORTED = 'quant_api.connection_reported';
+
const SETTINGS = 'quant_api.settings';
/**
@@ -281,15 +286,15 @@ protected function showStatus(): void {
return;
}
if ($this->client->ping()) {
- // After a project pick, the pick already said where the site publishes.
- if ($this->messenger()->messagesByType('status') !== []) {
+ // After a project pick or a confirm, that message already said so.
+ if ($this->getRequest()->getSession()->remove(self::REPORTED)) {
return;
}
$this->messenger()->addStatus($this->t('Connected to the Quant project %project.', ['%project' => $this->client->getProject()]));
return;
}
// The client has already said why when Quant could not be reached.
- if ($this->messenger()->messagesByType('error') === []) {
+ if (!$this->client->lastCallUnreachable()) {
$this->messenger()->addError($this->t('The Quant API did not accept the connection. Check the platform and the project, or log in to Quant again.'));
}
}
@@ -300,6 +305,9 @@ protected function showStatus(): void {
protected function showOverrides(array &$form): void {
$overrides = $this->client->getOverrides();
foreach ($overrides as $key => $value) {
+ if ($key === 'api_endpoint') {
+ $value = Credentials::redact((string) $value);
+ }
$message = $key === 'api_token'
? $this->t('QuantAPI override: api_token has been overridden in the settings file.')
: $this->t('QuantAPI override: @key has been overridden in the settings file with @value.', [
@@ -392,6 +400,7 @@ public function submitSameSite(array &$form, FormStateInterface $form_state) {
'@components' => implode(', ', $differences),
]);
$this->messenger()->addStatus($this->t('Confirmed. This site publishes with its Quant login again.'));
+ $this->getRequest()->getSession()->set(self::REPORTED, TRUE);
}
/**
diff --git a/modules/quant_api/src/OAuth/AuthService.php b/modules/quant_api/src/OAuth/AuthService.php
index a8d78a3d..1f7bb972 100644
--- a/modules/quant_api/src/OAuth/AuthService.php
+++ b/modules/quant_api/src/OAuth/AuthService.php
@@ -196,6 +196,8 @@ public function exchangeCode(string $code, string $redirect_uri, string $code_ve
* The user name and email, for display only.
*/
public function saveConnection(array $tokens, string $write_token, array $user): void {
+ // The login this one replaces (Change project) is ended, not orphaned.
+ $this->revokeStoredLogin();
$this->tokenStore->clear();
$this->tokenStore->saveTokens($tokens);
$this->tokenStore->setMany([
@@ -213,17 +215,32 @@ public function saveConnection(array $tokens, string $write_token, array $user):
* its own. The local connection is deleted in every case.
*/
public function disconnect(): void {
- $access = $this->tokenStore->get(OAuthTokenStore::ACCESS_TOKEN);
- if (is_string($access) && $access !== '') {
- $this->revokeAtDashboard($access);
- }
+ $this->revokeStoredLogin();
$this->tokenStore->clear();
}
/**
- * Asks the dashboard to delete this site's token (POST /api/oauth/revoke).
+ * Ends the stored login at the dashboard, if it belongs to this site.
+ *
+ * A copy of another site's database (for example staging from production)
+ * holds the original site's login. Revoking it would log the original site
+ * out, so a copy only forgets it locally.
+ */
+ protected function revokeStoredLogin(): void {
+ $record = $this->tokenStore->getAll();
+ $access = $record[OAuthTokenStore::ACCESS_TOKEN] ?? NULL;
+ if (!is_string($access) || $access === '' || $this->isRecordFromAnotherSite($record)) {
+ return;
+ }
+ $this->revokeToken($access);
+ }
+
+ /**
+ * Asks the dashboard to delete one token (POST /api/oauth/revoke).
+ *
+ * For a token this site holds or has just received. Best effort.
*/
- protected function revokeAtDashboard(string $access_token): void {
+ public function revokeToken(string $access_token): void {
try {
$response = $this->httpClient->request('POST', $this->getInternalDashboardUrl() . '/api/oauth/revoke', [
'headers' => [
@@ -300,7 +317,9 @@ public function getReconnectReason(): ?string {
if (!empty($record[OAuthTokenStore::REFUSAL])) {
return self::RECONNECT_REFUSED;
}
- return $this->isExpiring($record, 0) ? self::RECONNECT_EXPIRED : NULL;
+ // An expired access token is renewed on the next request while a
+ // refresh token is stored; only without one is a new login needed.
+ return empty($record[OAuthTokenStore::REFRESH_TOKEN]) && $this->isExpiring($record, 0) ? self::RECONNECT_EXPIRED : NULL;
}
/**
diff --git a/modules/quant_api/tests/src/Kernel/OAuthAuthServiceTest.php b/modules/quant_api/tests/src/Kernel/OAuthAuthServiceTest.php
index 4565f9cf..dd4e2c73 100644
--- a/modules/quant_api/tests/src/Kernel/OAuthAuthServiceTest.php
+++ b/modules/quant_api/tests/src/Kernel/OAuthAuthServiceTest.php
@@ -7,6 +7,7 @@
use GuzzleHttp\Promise\Create;
use Drupal\quant_api\Http\UnauthorizedResyncMiddleware;
use Drupal\KernelTests\KernelTestBase;
+use Drupal\quant_api\OAuth\AuthService;
use Drupal\quant_api\OAuth\OAuthTokenStore;
use Drupal\quant_api\OAuth\Platform;
use GuzzleHttp\Client;
@@ -503,4 +504,62 @@ public function testInternalDashboardForServerCalls(): void {
$this->assertSame('http://localhost:8002', $this->auth()->getInternalDashboardUrl());
}
+ /**
+ * Log out revokes this site's own login at the dashboard.
+ *
+ * @covers ::disconnect
+ */
+ public function testLogOutRevokesTheLogin(): void {
+ $this->connect(86400 * 60);
+ $this->mock->append(self::json(200, ['revoked' => TRUE]));
+ $this->auth()->disconnect();
+ $this->assertCount(1, $this->history);
+ $this->assertStringEndsWith('/api/oauth/revoke', (string) $this->history[0]['request']->getUri());
+ $this->assertSame([], array_filter($this->store()->getAll()));
+ }
+
+ /**
+ * Log out on a copy of the database never revokes the original's login.
+ *
+ * @covers ::disconnect
+ */
+ public function testLogOutOnCopiedSiteDoesNotRevoke(): void {
+ $this->connect(86400 * 60);
+ $fingerprint = $this->store()->get(OAuthTokenStore::FINGERPRINT);
+ $fingerprint['database'] = hash('sha256', 'production database');
+ $this->store()->set(OAuthTokenStore::FINGERPRINT, $fingerprint);
+ // A call would be answered (and recorded), so silence means no call.
+ $this->mock->append(self::json(200, ['revoked' => TRUE]));
+
+ $this->auth()->disconnect();
+ $this->assertCount(0, $this->history, 'The production login is left alone.');
+ $this->assertSame([], array_filter($this->store()->getAll()));
+ }
+
+ /**
+ * A new connection revokes the login it replaces (Change project).
+ *
+ * @covers ::saveConnection
+ */
+ public function testNewConnectionRevokesTheOldLogin(): void {
+ $this->connect(86400 * 60);
+ $this->mock->append(self::json(200, ['revoked' => TRUE]));
+ $this->auth()->saveConnection(['access_token' => 'qca_next', 'refresh_token' => 'qcr_next', 'expires_in' => 3600], 'write-token-2', ['name' => 'Editor']);
+ $this->assertCount(1, $this->history);
+ $this->assertSame('Bearer qca_old', $this->history[0]['request']->getHeaderLine('Authorization'));
+ $this->assertSame('qca_next', $this->store()->get(OAuthTokenStore::ACCESS_TOKEN));
+ }
+
+ /**
+ * An expired access token with a refresh token is not "needs a new login".
+ *
+ * @covers ::getReconnectReason
+ */
+ public function testExpiredAccessTokenWithRefreshNeedsNoNewLogin(): void {
+ $this->connect(3600, [OAuthTokenStore::EXPIRES_AT => time() - 60]);
+ $this->assertNull($this->auth()->getReconnectReason());
+ $this->store()->delete([OAuthTokenStore::REFRESH_TOKEN]);
+ $this->assertSame(AuthService::RECONNECT_EXPIRED, $this->auth()->getReconnectReason());
+ }
+
}
diff --git a/modules/quant_api/tests/src/Unit/QuantClientTest.php b/modules/quant_api/tests/src/Unit/QuantClientTest.php
index 0d9f1f45..cccff770 100644
--- a/modules/quant_api/tests/src/Unit/QuantClientTest.php
+++ b/modules/quant_api/tests/src/Unit/QuantClientTest.php
@@ -552,6 +552,7 @@ public function testUnreachableEndpointIsReportedPlainly() : void {
$client = $this->client([$down(), $down(), $down()]);
$this->assertFalse($client->ping());
+ $this->assertTrue($client->lastCallUnreachable());
$this->assertFalse($client->project());
$this->assertFalse($client->search());
$this->assertNotEmpty($shown);
From fa54a51e28dc290a562101ed18df574719641257 Mon Sep 17 00:00:00 2001
From: Stuart Rowlands
Date: Mon, 9 Nov 2026 09:32:48 +1000
Subject: [PATCH 23/30] fix(quant): check the translation in the alias language
before redirecting a renamed page
---
src/Seed.php | 9 +++++++--
1 file changed, 7 insertions(+), 2 deletions(-)
diff --git a/src/Seed.php b/src/Seed.php
index 5ce7bedb..3af030d9 100644
--- a/src/Seed.php
+++ b/src/Seed.php
@@ -614,7 +614,7 @@ public static function unpublishPathAlias($pathAlias) {
* The alias after the change.
*/
public static function redirectChangedAlias($original, $pathAlias) : void {
- if (\Drupal::moduleHandler()->moduleExists('redirect') || !self::isPublishedEntityPath((string) $pathAlias->get('path')->value)) {
+ if (\Drupal::moduleHandler()->moduleExists('redirect') || !self::isPublishedEntityPath((string) $pathAlias->get('path')->value, (string) $pathAlias->get('langcode')->value)) {
return;
}
$from = self::aliasUrl($original);
@@ -641,13 +641,18 @@ protected static function aliasUrl($pathAlias) : string {
/**
* Whether a system path is a published node or taxonomy term.
+ *
+ * The translation in the alias language decides, when it exists.
*/
- protected static function isPublishedEntityPath(string $path) : bool {
+ protected static function isPublishedEntityPath(string $path, string $langcode) : bool {
if (!preg_match('#^/(node|taxonomy/term)/(\d+)$#', $path, $m)) {
return FALSE;
}
$type = $m[1] === 'node' ? 'node' : 'taxonomy_term';
$entity = \Drupal::entityTypeManager()->getStorage($type)->load($m[2]);
+ if ($entity && method_exists($entity, 'hasTranslation') && $entity->hasTranslation($langcode)) {
+ $entity = $entity->getTranslation($langcode);
+ }
return $entity && (!method_exists($entity, 'isPublished') || $entity->isPublished());
}
From cff9ff46adaec71a9565ed3a8c2144da170edc97 Mon Sep 17 00:00:00 2001
From: Stuart Rowlands
Date: Mon, 9 Nov 2026 13:54:00 +1000
Subject: [PATCH 24/30] fix(quant_api): a logged-in site shows the platform of
its login
With a dashboard override outside quantgov.cloud (for example a local
QuantGov dashboard), the platform radio defaulted to QuantCDN while the
saved platform was QuantGov, so saving the settings form for another reason
logged the site out.
---
modules/quant_api/src/Form/SettingsForm.php | 5 +++++
.../tests/src/Functional/OAuthConnectTest.php | 19 +++++++++++++++++++
2 files changed, 24 insertions(+)
diff --git a/modules/quant_api/src/Form/SettingsForm.php b/modules/quant_api/src/Form/SettingsForm.php
index 81b12cf8..409dfe81 100644
--- a/modules/quant_api/src/Form/SettingsForm.php
+++ b/modules/quant_api/src/Form/SettingsForm.php
@@ -480,6 +480,11 @@ protected function savePlatform(FormStateInterface $form_state) {
* whatever the editor picks. Otherwise the saved platform, then the endpoint.
*/
protected function defaultPlatform(?string $saved, string $endpoint): string {
+ // A site that is logged in shows the platform of its login, so a save for
+ // another reason never reads as a platform change (and a log out).
+ if (Platform::isKnown($saved) && $this->credentials->usesOauth()) {
+ return $saved;
+ }
$dashboard = (string) Settings::get('quant_api_dashboard_url', '');
if ($dashboard !== '') {
return Platform::fromEndpoint($dashboard);
diff --git a/modules/quant_api/tests/src/Functional/OAuthConnectTest.php b/modules/quant_api/tests/src/Functional/OAuthConnectTest.php
index 016e19f4..5bc91c41 100644
--- a/modules/quant_api/tests/src/Functional/OAuthConnectTest.php
+++ b/modules/quant_api/tests/src/Functional/OAuthConnectTest.php
@@ -280,6 +280,25 @@ public function testEndpointOverrideIsReadOnly(): void {
$session->responseNotContains('rehearsal:');
}
+ /**
+ * A connected site shows its saved platform, so a save keeps the login.
+ *
+ * With a dashboard override outside quantgov.cloud (a local QuantGov
+ * dashboard), the radio used to default to QuantCDN while the saved
+ * platform was QuantGov. Saving the form for another reason then logged
+ * the site out.
+ */
+ public function testSaveKeepsTheLoginWithAnOverrideOnAnotherHost(): void {
+ $this->logInAndPick('site-b');
+ $this->config('quant_api.settings')->set('platform', 'quantgov')->save();
+
+ $this->drupalGet('admin/config/development/quant/api');
+ $this->assertSession()->checkboxChecked('edit-platform-quantgov');
+ $this->submitForm([], 'Save configuration');
+ $this->assertSession()->pageTextNotContains('The platform changed');
+ $this->assertSame('oauth', $this->config('quant_api.settings')->get('auth_method'));
+ }
+
/**
* A QuantGov dashboard in settings.php makes QuantGov the starting platform.
*/
From 21ea4fb775c0410912bf6d607aed589a1f160e89 Mon Sep 17 00:00:00 2001
From: Stuart Rowlands
Date: Mon, 9 Nov 2026 14:05:17 +1000
Subject: [PATCH 25/30] feat(quant_api): show editors the pushes that did not
reach Quant
Pushes run after the response is sent (and from the queue on cron), so a
failed push was only in the log. Each failure is now kept for the site, with
a short reason and never the error text, until a later push of the same page
succeeds. Editors (configure quant or access content overview) see a warning
on their next page view, and the status report lists the pages. Also: a
blank or invalid pagination selector no longer raises PHP warnings.
---
modules/quant_api/quant_api.install | 40 ++++-
modules/quant_api/quant_api.module | 26 ++++
modules/quant_api/quant_api.services.yml | 7 +
.../src/EventSubscriber/QuantApi.php | 32 +++-
modules/quant_api/src/PushFailures.php | 91 ++++++++++++
.../tests/src/Functional/OAuthConnectTest.php | 21 +++
.../tests/src/Kernel/PushFailureTest.php | 139 ++++++++++++++++++
.../tests/src/Unit/QuantApiFilesTest.php | 3 +-
8 files changed, 349 insertions(+), 10 deletions(-)
create mode 100644 modules/quant_api/src/PushFailures.php
create mode 100644 modules/quant_api/tests/src/Kernel/PushFailureTest.php
diff --git a/modules/quant_api/quant_api.install b/modules/quant_api/quant_api.install
index 9aa261ea..737ed102 100644
--- a/modules/quant_api/quant_api.install
+++ b/modules/quant_api/quant_api.install
@@ -15,16 +15,17 @@ function quant_api_requirements($phase) {
if ($phase !== 'runtime') {
return [];
}
+ $requirements = _quant_api_push_failure_requirements();
$credentials = \Drupal::service('quant_api.credentials');
if (!$credentials->usesOauth()) {
- return [];
+ return $requirements;
}
if (!$credentials->isCopiedSite()) {
$reason = \Drupal::service('quant_api.oauth')->getReconnectReason();
if ($reason === NULL) {
- return [];
+ return $requirements;
}
- return [
+ return $requirements + [
'quant_api_login' => [
'title' => t('Quant'),
'value' => t('The Quant login needs renewing'),
@@ -35,7 +36,7 @@ function quant_api_requirements($phase) {
],
];
}
- return [
+ return $requirements + [
'quant_api_copied_site' => [
'title' => t('Quant'),
'value' => t('Not publishing: this site looks like a copy of the connected site'),
@@ -48,6 +49,37 @@ function quant_api_requirements($phase) {
];
}
+/**
+ * The status report entry for pushes that did not reach Quant.
+ */
+function _quant_api_push_failure_requirements(): array {
+ $failures = \Drupal::service('quant_api.push_failures')->all();
+ if ($failures === []) {
+ return [];
+ }
+ $items = [];
+ foreach (array_slice(array_reverse($failures, TRUE), 0, 10, TRUE) as $url => $failure) {
+ $items[] = t('@url (@operation, @reason, @date)', [
+ '@url' => $url,
+ '@operation' => $failure['operation'],
+ '@reason' => $failure['reason'],
+ '@date' => \Drupal::service('date.formatter')->format($failure['at'], 'custom', 'Y-m-d H:i'),
+ ]);
+ }
+ $list = ['#theme' => 'item_list', '#items' => $items];
+ return [
+ 'quant_api_push_failures' => [
+ 'title' => t('Quant'),
+ 'value' => \Drupal::translation()->formatPlural(count($failures), '1 change did not reach Quant', '@count changes did not reach Quant'),
+ 'description' => [
+ 'list' => $list,
+ 'help' => ['#markup' => t('These pages are not up to date on the static site. After Quant is back, save each page again or run a seed. An entry goes away when its page is pushed.')],
+ ],
+ 'severity' => REQUIREMENT_WARNING,
+ ],
+ ];
+}
+
/**
* Implements hook_uninstall().
*/
diff --git a/modules/quant_api/quant_api.module b/modules/quant_api/quant_api.module
index e1d07938..74d80691 100644
--- a/modules/quant_api/quant_api.module
+++ b/modules/quant_api/quant_api.module
@@ -17,3 +17,29 @@ function quant_api_cron() {
\Drupal::service('quant_api.write_token_sync')->syncIfDue($refreshed);
}
}
+
+/**
+ * Implements hook_page_top().
+ *
+ * Pushes run after the response is sent, so an editor never saw one fail.
+ * The failures are kept for the site, and every editor sees them on the next
+ * page view until the pages are pushed again.
+ */
+function quant_api_page_top(array &$page_top) {
+ $account = \Drupal::currentUser();
+ if (!$account->hasPermission('configure quant') && !$account->hasPermission('access content overview')) {
+ return;
+ }
+ $failures = \Drupal::service('quant_api.push_failures')->all();
+ if ($failures === []) {
+ return;
+ }
+ $last = array_key_last($failures);
+ \Drupal::messenger()->addWarning(\Drupal::translation()->formatPlural(
+ count($failures),
+ '1 change did not reach Quant (@url: @reason). It is not on the static site yet. After Quant is back, save the page again or run a seed.',
+ '@count changes did not reach Quant (the last: @url, @reason). They are not on the static site yet. After Quant is back, save the pages again or run a seed. The status report lists them.',
+ ['@url' => $last, '@reason' => $failures[$last]['reason']]
+ ));
+ $page_top['#cache']['max-age'] = 0;
+}
diff --git a/modules/quant_api/quant_api.services.yml b/modules/quant_api/quant_api.services.yml
index 0a4d6256..c2d4c790 100644
--- a/modules/quant_api/quant_api.services.yml
+++ b/modules/quant_api/quant_api.services.yml
@@ -50,6 +50,7 @@ services:
- '@quant_api.client'
- '@logger.factory'
- '@event_dispatcher'
+ - '@quant_api.push_failures'
tags:
- { name: 'event_subscriber' }
@@ -68,6 +69,12 @@ services:
tags:
- { name: http_client_middleware }
+ quant_api.push_failures:
+ class: Drupal\quant_api\PushFailures
+ arguments:
+ - '@state'
+ - '@datetime.time'
+
quant_api.endpoint_userinfo_middleware:
class: Drupal\quant_api\Http\EndpointUserInfoMiddleware
tags:
diff --git a/modules/quant_api/src/EventSubscriber/QuantApi.php b/modules/quant_api/src/EventSubscriber/QuantApi.php
index 6afb934c..ee373334 100644
--- a/modules/quant_api/src/EventSubscriber/QuantApi.php
+++ b/modules/quant_api/src/EventSubscriber/QuantApi.php
@@ -15,6 +15,7 @@
use Drupal\quant\Utility;
use Drupal\quant_api\Client\QuantClientInterface;
use Drupal\quant_api\Exception\InvalidPayload;
+use Drupal\quant_api\PushFailures;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
@@ -44,6 +45,13 @@ class QuantApi implements EventSubscriberInterface {
*/
protected $eventDispatcher;
+ /**
+ * The pages whose last push failed.
+ *
+ * @var \Drupal\quant_api\PushFailures
+ */
+ protected $failures;
+
/**
* QuantAPI event subscriber.
*
@@ -57,8 +65,9 @@ class QuantApi implements EventSubscriberInterface {
* @param \Symfony\Component\EventDispatcher\EventDispatcher $event_dispatcher
* The event dispatcher.
*/
- public function __construct(QuantClientInterface $client, LoggerChannelFactoryInterface $logger_factory, EventDispatcher $event_dispatcher) {
+ public function __construct(QuantClientInterface $client, LoggerChannelFactoryInterface $logger_factory, EventDispatcher $event_dispatcher, PushFailures $failures) {
$this->client = $client;
+ $this->failures = $failures;
$this->logger = $logger_factory->get('quant_api');
$this->eventDispatcher = $event_dispatcher;
}
@@ -104,8 +113,10 @@ public function onRedirect(QuantRedirectEvent $event) {
}
catch (\Exception $error) {
$this->logger->error($error->getMessage());
+ $this->failures->record((string) $source, 'redirect', $error);
return;
}
+ $this->failures->clear((string) $source);
return $res;
}
@@ -155,8 +166,10 @@ public function onOutput(QuantEvent $event) {
}
catch (\Exception $error) {
$this->logger->error($error->getMessage());
+ $this->failures->record($path, 'publish', $error);
return FALSE;
}
+ $this->failures->clear($path);
$queue_factory = QuantQueueFactory::getInstance();
$queue = $queue_factory->get('quant_seed_worker');
@@ -238,9 +251,14 @@ protected function pushAttachments(array $res, QueueInterface $queue) : array {
protected function queuePaginationLinks(\DOMXPath $xpath, string $path, QueueInterface $queue) : void {
$links_config = \Drupal::config('quant.settings')->get('xpath_selectors');
- foreach (explode(PHP_EOL, (string) $links_config) as $links_line) {
+ foreach (preg_split('/\R/', (string) $links_config) as $links_line) {
+ // A blank line or an invalid expression selects nothing.
+ $nodes = trim($links_line) === '' ? FALSE : @$xpath->query(trim($links_line));
+ if ($nodes === FALSE) {
+ continue;
+ }
/** @var \DOMElement $node */
- foreach ($xpath->query(trim($links_line)) as $node) {
+ foreach ($nodes as $node) {
$original_href = $new_href = $node->getAttribute('href');
if ($original_href[0] === '?') {
$new_href = strtok($path, '?') . $original_href;
@@ -534,9 +552,11 @@ public function onMedia(QuantFileEvent $event) {
// or the file silently never reaches Quant.
if (!str_contains($error->getMessage(), 'MD5 already matches')) {
$this->logger->error($error->getMessage());
+ $this->failures->record($url, 'file', $error);
}
return;
}
+ $this->failures->clear($url);
return $res;
}
@@ -553,11 +573,13 @@ public function onUnpublish(QuantEvent $event) {
// Don't log it if it's a 404, since the content is unpublished.
if (!str_contains($error->getMessage(), '404 Not Found') && !str_contains($error->getMessage(), 'Resource is already unpublished')) {
$this->logger->error($error->getMessage());
+ $this->failures->record($url, 'unpublish', $error);
+ return;
}
- return;
}
+ $this->failures->clear($url);
- return $res;
+ return $res ?? NULL;
}
}
diff --git a/modules/quant_api/src/PushFailures.php b/modules/quant_api/src/PushFailures.php
new file mode 100644
index 00000000..c62a3849
--- /dev/null
+++ b/modules/quant_api/src/PushFailures.php
@@ -0,0 +1,91 @@
+all();
+ unset($all[$url]);
+ $all[$url] = [
+ 'operation' => $operation,
+ 'reason' => self::reason($error),
+ 'at' => $this->time->getRequestTime(),
+ ];
+ $this->state->set(self::KEY, array_slice($all, -self::MAX, NULL, TRUE));
+ }
+
+ /**
+ * Forgets a page after a push of it succeeded.
+ */
+ public function clear(string $url): void {
+ $all = $this->all();
+ if (isset($all[$url])) {
+ unset($all[$url]);
+ $this->state->set(self::KEY, $all);
+ }
+ }
+
+ /**
+ * The failed pages, oldest first, keyed by path.
+ *
+ * @return array
+ * The failures.
+ */
+ public function all(): array {
+ return (array) $this->state->get(self::KEY, []);
+ }
+
+ /**
+ * A short reason that is safe to show to an editor.
+ */
+ protected static function reason(\Throwable $error): string {
+ if ($error instanceof ConnectException) {
+ return 'Quant could not be reached';
+ }
+ if ($error instanceof RequestException && $error->getResponse()) {
+ return 'Quant answered ' . $error->getResponse()->getStatusCode();
+ }
+ return 'the request failed';
+ }
+
+}
diff --git a/modules/quant_api/tests/src/Functional/OAuthConnectTest.php b/modules/quant_api/tests/src/Functional/OAuthConnectTest.php
index 5bc91c41..50fcfd7b 100644
--- a/modules/quant_api/tests/src/Functional/OAuthConnectTest.php
+++ b/modules/quant_api/tests/src/Functional/OAuthConnectTest.php
@@ -280,6 +280,27 @@ public function testEndpointOverrideIsReadOnly(): void {
$session->responseNotContains('rehearsal:');
}
+ /**
+ * A push that failed after a save is shown on the next page view.
+ */
+ public function testFailedPushIsShownOnTheNextPageView(): void {
+ \Drupal::service('quant_api.push_failures')->record('/news/a', 'publish', new \Exception('x'));
+ $this->drupalGet('admin/config/development/quant/api');
+ $session = $this->assertSession();
+ $session->pageTextContains('1 change did not reach Quant');
+ $session->pageTextContains('/news/a');
+
+ // Another editor of the same site sees it too: it is per site.
+ $this->drupalLogin($this->drupalCreateUser(['access content overview']));
+ $this->drupalGet('admin/content');
+ $session->pageTextContains('1 change did not reach Quant');
+
+ // A visitor does not.
+ $this->drupalLogout();
+ $this->drupalGet('user/login');
+ $session->pageTextNotContains('did not reach Quant');
+ }
+
/**
* A connected site shows its saved platform, so a save keeps the login.
*
diff --git a/modules/quant_api/tests/src/Kernel/PushFailureTest.php b/modules/quant_api/tests/src/Kernel/PushFailureTest.php
new file mode 100644
index 00000000..db2f9968
--- /dev/null
+++ b/modules/quant_api/tests/src/Kernel/PushFailureTest.php
@@ -0,0 +1,139 @@
+createMock(QuantClientInterface::class);
+ foreach (['send', 'sendRedirect', 'unpublish'] as $method) {
+ $client->method($method)->willReturnCallback(function () {
+ if ($this->next instanceof \Throwable) {
+ throw $this->next;
+ }
+ return $this->next;
+ });
+ }
+ $this->container->set('quant_api.client', $client);
+ $this->container->set('quant.output.api', NULL);
+ }
+
+ /**
+ * The subscriber under test, built with the stub client.
+ */
+ protected function subscriber() {
+ return $this->container->get('quant.output.api');
+ }
+
+ /**
+ * A content push event for a path.
+ */
+ protected static function page(string $path): QuantEvent {
+ return new QuantEvent('Page', $path, [
+ 'published' => TRUE,
+ 'transitions' => [],
+ 'info' => [],
+ 'proxy_override' => FALSE,
+ ]);
+ }
+
+ /**
+ * A failed push is stored with a short reason; a later success clears it.
+ */
+ public function testFailureIsKeptUntilThePageIsPushed(): void {
+ $failures = $this->container->get('quant_api.push_failures');
+
+ $this->next = new RequestException('Server error with the token in it', new Request('POST', 'https://user:secret@api.example/v1'), new Response(503));
+ $this->subscriber()->onOutput(self::page('/news/a'));
+ $this->next = new ConnectException('cURL error 7 for https://user:secret@api.example', new Request('POST', 'https://api.example'));
+ $this->subscriber()->onRedirect(new QuantRedirectEvent('/old', '/news/a', 301));
+
+ $all = $failures->all();
+ $this->assertSame(['/news/a', '/old'], array_keys($all));
+ $this->assertSame('publish', $all['/news/a']['operation']);
+ $this->assertSame('Quant answered 503', $all['/news/a']['reason']);
+ $this->assertSame('Quant could not be reached', $all['/old']['reason']);
+ $this->assertStringNotContainsString('secret', serialize($all));
+
+ // The status report says so.
+ \Drupal::moduleHandler()->loadInclude('quant_api', 'install');
+ $requirements = quant_api_requirements('runtime');
+ $this->assertArrayHasKey('quant_api_push_failures', $requirements);
+ $this->assertSame(REQUIREMENT_WARNING, $requirements['quant_api_push_failures']['severity']);
+ $items = implode(' ', array_map('strval', $requirements['quant_api_push_failures']['description']['list']['#items']));
+ $this->assertStringContainsString('/news/a (publish, Quant answered 503', $items);
+
+ // A later successful push of the same page clears its failure only.
+ $this->next = [];
+ $this->subscriber()->onOutput(self::page('/news/a'));
+ $this->assertSame(['/old'], array_keys($failures->all()));
+ }
+
+ /**
+ * An unpublish of a page that is already gone is not a failure.
+ */
+ public function testAlreadyUnpublishedIsNotAFailure(): void {
+ $this->next = new \Exception('Resource is already unpublished');
+ $this->subscriber()->onUnpublish(new QuantEvent('', '/gone', []));
+ $this->assertSame([], $this->container->get('quant_api.push_failures')->all());
+ }
+
+ /**
+ * The list keeps the newest 50 pages.
+ */
+ public function testTheListIsCapped(): void {
+ $failures = $this->container->get('quant_api.push_failures');
+ for ($i = 1; $i <= 55; $i++) {
+ $failures->record('/p' . $i, 'publish', new \Exception('x'));
+ }
+ $all = $failures->all();
+ $this->assertCount(50, $all);
+ $this->assertArrayNotHasKey('/p1', $all);
+ $this->assertArrayHasKey('/p55', $all);
+ }
+
+}
diff --git a/modules/quant_api/tests/src/Unit/QuantApiFilesTest.php b/modules/quant_api/tests/src/Unit/QuantApiFilesTest.php
index e093079c..92744b58 100644
--- a/modules/quant_api/tests/src/Unit/QuantApiFilesTest.php
+++ b/modules/quant_api/tests/src/Unit/QuantApiFilesTest.php
@@ -14,6 +14,7 @@
use Drupal\quant\Event\QuantFileEvent;
use Drupal\quant_api\Client\QuantClientInterface;
use Drupal\quant_api\EventSubscriber\QuantApi;
+use Drupal\quant_api\PushFailures;
use Drupal\Tests\UnitTestCase;
use Symfony\Component\EventDispatcher\EventDispatcher;
@@ -117,7 +118,7 @@ protected function setUp(): void {
});
$this->client = $this->createMock(QuantClientInterface::class);
- $this->subscriber = new QuantApi($this->client, $logger_factory, $dispatcher);
+ $this->subscriber = new QuantApi($this->client, $logger_factory, $dispatcher, $this->createMock(PushFailures::class));
}
/**
From bbd05a60297b905c6ef501cd0964b23fcfd3257d Mon Sep 17 00:00:00 2001
From: Stuart Rowlands
Date: Mon, 9 Nov 2026 14:13:09 +1000
Subject: [PATCH 26/30] style(quant_api): document the push failure store; test
name in lowerCamel
---
modules/quant_api/src/EventSubscriber/QuantApi.php | 2 ++
modules/quant_api/tests/src/Kernel/PushFailureTest.php | 2 +-
2 files changed, 3 insertions(+), 1 deletion(-)
diff --git a/modules/quant_api/src/EventSubscriber/QuantApi.php b/modules/quant_api/src/EventSubscriber/QuantApi.php
index ee373334..26877f79 100644
--- a/modules/quant_api/src/EventSubscriber/QuantApi.php
+++ b/modules/quant_api/src/EventSubscriber/QuantApi.php
@@ -64,6 +64,8 @@ class QuantApi implements EventSubscriberInterface {
* The logger channel factory.
* @param \Symfony\Component\EventDispatcher\EventDispatcher $event_dispatcher
* The event dispatcher.
+ * @param \Drupal\quant_api\PushFailures $failures
+ * The pages whose last push failed.
*/
public function __construct(QuantClientInterface $client, LoggerChannelFactoryInterface $logger_factory, EventDispatcher $event_dispatcher, PushFailures $failures) {
$this->client = $client;
diff --git a/modules/quant_api/tests/src/Kernel/PushFailureTest.php b/modules/quant_api/tests/src/Kernel/PushFailureTest.php
index db2f9968..6b47f6ae 100644
--- a/modules/quant_api/tests/src/Kernel/PushFailureTest.php
+++ b/modules/quant_api/tests/src/Kernel/PushFailureTest.php
@@ -116,7 +116,7 @@ public function testFailureIsKeptUntilThePageIsPushed(): void {
/**
* An unpublish of a page that is already gone is not a failure.
*/
- public function testAlreadyUnpublishedIsNotAFailure(): void {
+ public function testAlreadyUnpublishedIsNoFailure(): void {
$this->next = new \Exception('Resource is already unpublished');
$this->subscriber()->onUnpublish(new QuantEvent('', '/gone', []));
$this->assertSame([], $this->container->get('quant_api.push_failures')->all());
From 75012b89b502cde037bdf702904ae94d4a7444a0 Mon Sep 17 00:00:00 2001
From: Stuart Rowlands
Date: Mon, 9 Nov 2026 14:18:51 +1000
Subject: [PATCH 27/30] fix(quant_api): push failure warning survives page
caching; review fixes
From review: a cached editor page did not show a new failure (the list now
has a cache tag, invalidated when it changes); a file that Quant already had
(MD5 matches) never cleared its failure; concurrent queue workers could lose
entries (the list changes under a lock); the message pointed editors without
access to the status report.
---
modules/quant_api/quant_api.module | 7 ++-
modules/quant_api/quant_api.services.yml | 2 +
.../src/EventSubscriber/QuantApi.php | 3 +
modules/quant_api/src/PushFailures.php | 59 +++++++++++++++----
.../tests/src/Kernel/PushFailureTest.php | 17 ++++++
5 files changed, 76 insertions(+), 12 deletions(-)
diff --git a/modules/quant_api/quant_api.module b/modules/quant_api/quant_api.module
index 74d80691..e780bcd6 100644
--- a/modules/quant_api/quant_api.module
+++ b/modules/quant_api/quant_api.module
@@ -5,6 +5,8 @@
* Hooks for the Quant API module.
*/
+use Drupal\quant_api\PushFailures;
+
/**
* Implements hook_cron().
*/
@@ -30,6 +32,9 @@ function quant_api_page_top(array &$page_top) {
if (!$account->hasPermission('configure quant') && !$account->hasPermission('access content overview')) {
return;
}
+ // Cached pages are invalidated when the list changes (PushFailures).
+ $page_top['#cache']['tags'][] = PushFailures::CACHE_TAG;
+ $page_top['#cache']['contexts'][] = 'user.permissions';
$failures = \Drupal::service('quant_api.push_failures')->all();
if ($failures === []) {
return;
@@ -38,7 +43,7 @@ function quant_api_page_top(array &$page_top) {
\Drupal::messenger()->addWarning(\Drupal::translation()->formatPlural(
count($failures),
'1 change did not reach Quant (@url: @reason). It is not on the static site yet. After Quant is back, save the page again or run a seed.',
- '@count changes did not reach Quant (the last: @url, @reason). They are not on the static site yet. After Quant is back, save the pages again or run a seed. The status report lists them.',
+ '@count changes did not reach Quant (the last: @url, @reason). They are not on the static site yet. After Quant is back, save the pages again or run a seed.',
['@url' => $last, '@reason' => $failures[$last]['reason']]
));
$page_top['#cache']['max-age'] = 0;
diff --git a/modules/quant_api/quant_api.services.yml b/modules/quant_api/quant_api.services.yml
index c2d4c790..86d3f728 100644
--- a/modules/quant_api/quant_api.services.yml
+++ b/modules/quant_api/quant_api.services.yml
@@ -74,6 +74,8 @@ services:
arguments:
- '@state'
- '@datetime.time'
+ - '@cache_tags.invalidator'
+ - '@lock'
quant_api.endpoint_userinfo_middleware:
class: Drupal\quant_api\Http\EndpointUserInfoMiddleware
diff --git a/modules/quant_api/src/EventSubscriber/QuantApi.php b/modules/quant_api/src/EventSubscriber/QuantApi.php
index 26877f79..fab62880 100644
--- a/modules/quant_api/src/EventSubscriber/QuantApi.php
+++ b/modules/quant_api/src/EventSubscriber/QuantApi.php
@@ -555,7 +555,10 @@ public function onMedia(QuantFileEvent $event) {
if (!str_contains($error->getMessage(), 'MD5 already matches')) {
$this->logger->error($error->getMessage());
$this->failures->record($url, 'file', $error);
+ return;
}
+ // Quant already has this file.
+ $this->failures->clear($url);
return;
}
$this->failures->clear($url);
diff --git a/modules/quant_api/src/PushFailures.php b/modules/quant_api/src/PushFailures.php
index c62a3849..940e0a06 100644
--- a/modules/quant_api/src/PushFailures.php
+++ b/modules/quant_api/src/PushFailures.php
@@ -3,6 +3,8 @@
namespace Drupal\quant_api;
use Drupal\Component\Datetime\TimeInterface;
+use Drupal\Core\Cache\CacheTagsInvalidatorInterface;
+use Drupal\Core\Lock\LockBackendInterface;
use Drupal\Core\State\StateInterface;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\RequestException;
@@ -27,9 +29,16 @@ class PushFailures {
*/
public const MAX = 50;
+ /**
+ * The cache tag of pages that show the list (or would show it).
+ */
+ public const CACHE_TAG = 'quant_api_push_failures';
+
public function __construct(
protected StateInterface $state,
protected TimeInterface $time,
+ protected CacheTagsInvalidatorInterface $invalidator,
+ protected LockBackendInterface $lock,
) {}
/**
@@ -44,24 +53,52 @@ public function __construct(
* name the endpoint URL with credentials.
*/
public function record(string $url, string $operation, \Throwable $error): void {
- $all = $this->all();
- unset($all[$url]);
- $all[$url] = [
- 'operation' => $operation,
- 'reason' => self::reason($error),
- 'at' => $this->time->getRequestTime(),
- ];
- $this->state->set(self::KEY, array_slice($all, -self::MAX, NULL, TRUE));
+ $this->update(function (array $all) use ($url, $operation, $error) {
+ unset($all[$url]);
+ $all[$url] = [
+ 'operation' => $operation,
+ 'reason' => self::reason($error),
+ 'at' => $this->time->getRequestTime(),
+ ];
+ return array_slice($all, -self::MAX, NULL, TRUE);
+ });
}
/**
* Forgets a page after a push of it succeeded.
*/
public function clear(string $url): void {
- $all = $this->all();
- if (isset($all[$url])) {
+ // Most pushes succeed: read without the lock first.
+ if (!isset($this->all()[$url])) {
+ return;
+ }
+ $this->update(function (array $all) use ($url) {
unset($all[$url]);
- $this->state->set(self::KEY, $all);
+ return $all;
+ });
+ }
+
+ /**
+ * Changes the list under a lock, so queue workers do not lose entries.
+ *
+ * @param callable $change
+ * Gets the list and returns the new list.
+ */
+ protected function update(callable $change): void {
+ $locked = $this->lock->acquire(self::KEY, 5.0) || (!$this->lock->wait(self::KEY, 5) && $this->lock->acquire(self::KEY, 5.0));
+ try {
+ $this->state->resetCache();
+ $before = $this->all();
+ $after = $change($before);
+ if ($after !== $before) {
+ $this->state->set(self::KEY, $after);
+ $this->invalidator->invalidateTags([self::CACHE_TAG]);
+ }
+ }
+ finally {
+ if ($locked) {
+ $this->lock->release(self::KEY);
+ }
}
}
diff --git a/modules/quant_api/tests/src/Kernel/PushFailureTest.php b/modules/quant_api/tests/src/Kernel/PushFailureTest.php
index 6b47f6ae..6f274717 100644
--- a/modules/quant_api/tests/src/Kernel/PushFailureTest.php
+++ b/modules/quant_api/tests/src/Kernel/PushFailureTest.php
@@ -6,6 +6,7 @@
use Drupal\quant\Event\QuantEvent;
use Drupal\quant\Event\QuantRedirectEvent;
use Drupal\quant_api\Client\QuantClientInterface;
+use Drupal\quant_api\PushFailures;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\Psr7\Request;
@@ -122,6 +123,22 @@ public function testAlreadyUnpublishedIsNoFailure(): void {
$this->assertSame([], $this->container->get('quant_api.push_failures')->all());
}
+ /**
+ * A change to the list invalidates pages that show it.
+ */
+ public function testChangeInvalidatesTheCacheTag(): void {
+ $failures = $this->container->get('quant_api.push_failures');
+ \Drupal::cache()->set('page', 'cached', -1, [PushFailures::CACHE_TAG]);
+ $failures->record('/a', 'publish', new \Exception('x'));
+ $this->assertFalse(\Drupal::cache()->get('page'), 'A new failure invalidates.');
+
+ \Drupal::cache()->set('page', 'cached', -1, [PushFailures::CACHE_TAG]);
+ $failures->clear('/not-listed');
+ $this->assertNotFalse(\Drupal::cache()->get('page'), 'No change, no invalidation.');
+ $failures->clear('/a');
+ $this->assertFalse(\Drupal::cache()->get('page'), 'A cleared failure invalidates.');
+ }
+
/**
* The list keeps the newest 50 pages.
*/
From 3ce31ec7163cc31b4c1eb3f2c176e16d767ea3a6 Mon Sep 17 00:00:00 2001
From: Stuart Rowlands
Date: Mon, 9 Nov 2026 17:22:27 +1000
Subject: [PATCH 28/30] fix(quant): store the on/off settings as booleans; run
functional tests in CI
CI (Drupal 11.1) failed: config/install/quant.settings.yml held 0/1 for keys
the schema types as boolean. Drupal 11.1 installs module config as trusted
data, without casting it, so the strict schema check saw integers. Drupal
11.4 casts on save, which is why a local 11.4 run passed. The install config
now holds true/false, and update 9009 casts integers that existing sites
stored.
CI now starts a PHP web server and sets SIMPLETEST_BASE_URL, so the
functional tests run instead of failing on the missing base URL.
---
.github/workflows/ci.yml | 19 +++++-
config/install/quant.settings.yml | 16 +++---
quant.install | 27 +++++++++
tests/src/Kernel/SettingsBooleanTest.php | 73 ++++++++++++++++++++++++
4 files changed, 124 insertions(+), 11 deletions(-)
create mode 100644 tests/src/Kernel/SettingsBooleanTest.php
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 99e45752..b1bd7aae 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -65,12 +65,25 @@ jobs:
run: mkdir -p /var/www/drupal/web/sites/simpletest/browser_output
working-directory: /var/www/drupal
- # Runs every unit and kernel test in the module and its submodules.
- # Previously this job installed the module and stopped, so a broken
- # test file went unnoticed.
+ # Functional tests drive the site over HTTP. Several workers, because a
+ # test page can call back into the same site (the fake dashboard).
+ - name: Start the web server
+ run: |
+ PHP_CLI_SERVER_WORKERS=4 nohup php -S 127.0.0.1:8080 .ht.router.php > /tmp/php-server.log 2>&1 &
+ for i in $(seq 1 30); do curl -s -o /dev/null http://127.0.0.1:8080/core/install.php && exit 0; sleep 1; done
+ cat /tmp/php-server.log; exit 1
+ working-directory: /var/www/drupal/web
+
+ # Runs every unit, kernel and functional test in the module and its
+ # submodules.
- name: Run tests
env:
SIMPLETEST_DB: mysql://root:drupal@mariadb:3306/drupal11
+ SIMPLETEST_BASE_URL: http://127.0.0.1:8080
BROWSERTEST_OUTPUT_DIRECTORY: /var/www/drupal/web/sites/simpletest/browser_output
run: vendor/bin/phpunit -c web/core --display-deprecations web/modules/custom/quant
working-directory: /var/www/drupal
+
+ - name: Web server log
+ if: failure()
+ run: tail -n 100 /tmp/php-server.log
diff --git a/config/install/quant.settings.yml b/config/install/quant.settings.yml
index e9790071..1ade6829 100644
--- a/config/install/quant.settings.yml
+++ b/config/install/quant.settings.yml
@@ -1,10 +1,10 @@
-quant_enabled: 1
-quant_enabled_nodes: 1
-quant_enabled_taxonomy: 1
-quant_enabled_views: 1
-quant_enabled_redirects: 1
+quant_enabled: true
+quant_enabled_nodes: true
+quant_enabled_taxonomy: true
+quant_enabled_views: true
+quant_enabled_redirects: true
quant_routes_export:
-ssl_cert_verify: 1
-disable_content_drafts: 1
-quant_show_page_info_block: 1
+ssl_cert_verify: true
+disable_content_drafts: true
+quant_show_page_info_block: true
xpath_selectors: "//li[contains(@class,\"pager__item--next\")]/a[contains(@href,\"page=\")]\r\n//li[contains(@class,\"pager__item--first\")]/a[starts-with(@href, \"/\")]\r\n//li[contains(@class,\"page-item\")]/a[contains(@href,\"page=\")]"
diff --git a/quant.install b/quant.install
index d64bf072..4ce03b52 100644
--- a/quant.install
+++ b/quant.install
@@ -204,3 +204,30 @@ function quant_update_9008() {
}
return implode('\n\n', $message);
}
+
+/**
+ * Store the on/off settings as booleans, as their schema says.
+ *
+ * Sites that installed the module on Drupal 11.1 or earlier kept 0 and 1 from
+ * config/install, which fails strict config schema checks.
+ */
+function quant_update_9009() {
+ $config = \Drupal::configFactory()->getEditable('quant.settings');
+ $keys = [
+ 'quant_enabled',
+ 'quant_enabled_nodes',
+ 'quant_enabled_taxonomy',
+ 'quant_enabled_views',
+ 'quant_enabled_redirects',
+ 'ssl_cert_verify',
+ 'disable_content_drafts',
+ 'quant_show_page_info_block',
+ ];
+ foreach ($keys as $key) {
+ $value = $config->get($key);
+ if ($value !== NULL && !is_bool($value)) {
+ $config->set($key, (bool) $value);
+ }
+ }
+ $config->save(TRUE);
+}
diff --git a/tests/src/Kernel/SettingsBooleanTest.php b/tests/src/Kernel/SettingsBooleanTest.php
new file mode 100644
index 00000000..b3063e6b
--- /dev/null
+++ b/tests/src/Kernel/SettingsBooleanTest.php
@@ -0,0 +1,73 @@
+getPath('quant');
+ $data = Yaml::decode(file_get_contents($this->root . '/' . $path . '/config/install/quant.settings.yml'));
+ foreach (self::KEYS as $key) {
+ $this->assertIsBool($data[$key], "$key is a boolean in config/install.");
+ }
+ }
+
+ /**
+ * The update casts integers that an older install stored.
+ */
+ public function testUpdateCastsStoredIntegers(): void {
+ $values = array_fill_keys(self::KEYS, 1);
+ $values['disable_content_drafts'] = 0;
+ // Written as given, as the installer of Drupal 11.1 stored it.
+ $this->container->get('config.storage')->write('quant.settings', $values + ['xpath_selectors' => '']);
+ $this->container->get('config.factory')->reset('quant.settings');
+ $this->assertSame(1, $this->config('quant.settings')->get('quant_enabled'));
+
+ \Drupal::moduleHandler()->loadInclude('quant', 'install');
+ quant_update_9009();
+
+ $settings = $this->config('quant.settings');
+ foreach (self::KEYS as $key) {
+ $this->assertIsBool($settings->get($key), "$key is a boolean after the update.");
+ }
+ $this->assertTrue($settings->get('quant_enabled'));
+ $this->assertFalse($settings->get('disable_content_drafts'));
+ }
+
+}
From cc6d4047fa5a989cd5a4f544e35595db1287eaaa Mon Sep 17 00:00:00 2001
From: Stuart Rowlands
Date: Mon, 9 Nov 2026 17:22:47 +1000
Subject: [PATCH 29/30] style(quant_api): comment line within 80 characters
---
modules/quant_api/tests/src/Functional/OAuthConnectTest.php | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/modules/quant_api/tests/src/Functional/OAuthConnectTest.php b/modules/quant_api/tests/src/Functional/OAuthConnectTest.php
index 50fcfd7b..783d3fed 100644
--- a/modules/quant_api/tests/src/Functional/OAuthConnectTest.php
+++ b/modules/quant_api/tests/src/Functional/OAuthConnectTest.php
@@ -220,7 +220,7 @@ public function testCompletedLoginSavesThePlatform(): void {
}
/**
- * A refused refresh is not shown as "Connected", and the status report says so.
+ * A refused refresh is not shown as "Connected"; the status report says so.
*/
public function testRefusedLoginIsShownToTheEditor(): void {
$this->logInAndPick('site-b');
From 9cfd18b4c8aab5ab4be1671a66a7b183681007ea Mon Sep 17 00:00:00 2001
From: Stuart Rowlands
Date: Mon, 9 Nov 2026 20:36:35 +1000
Subject: [PATCH 30/30] fix: every shipped config file matches its schema; CI
installs Webform
CI (Drupal 11.1, which installs module config without casting it) still
failed on quant.token_settings. A new kernel test checks every config/install
and config/optional file of quant and its submodules against its schema, as
the files are on disk, and lists every mismatch in one run. It found:
- quant.token_settings disable/strict and the Quant Search entity settings
stored 0/1 for booleans: now true/false; update 9009 casts them on
existing sites.
- The quant_metadata Views field option kept 0 for each unticked key (from
checkboxes), against its list-of-strings schema: the shipped views keep
only the ticked keys, the field drops unticked keys on save, and update
9010 filters saved views.
CI also installs drupal/webform, so the Quant Forms tests can run.
---
.github/workflows/ci.yml | 3 +-
config/install/quant.token_settings.yml | 4 +-
.../views.view.quant_metadata_file.yml | 9 --
.../views.view.quant_metadata_node.yml | 10 ---
.../views.view.quant_metadata_taxonomy.yml | 10 ---
.../views.view.quant_metadata_redirect.yml | 8 --
.../quant_search.entities.settings.yml | 4 +-
quant.install | 76 ++++++++++++----
src/Plugin/views/field/QuantMetadata.php | 15 ++++
tests/src/Kernel/SettingsBooleanTest.php | 46 +++++++---
tests/src/Kernel/ShippedConfigSchemaTest.php | 90 +++++++++++++++++++
11 files changed, 206 insertions(+), 69 deletions(-)
create mode 100644 tests/src/Kernel/ShippedConfigSchemaTest.php
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b1bd7aae..a3f14cb1 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -37,8 +37,9 @@ jobs:
# token is required by quant_search. purge is required by quant_purger,
# whose kernel tests install it, so the suite cannot run without it.
+ # webform is required by quant_webform and its tests.
- name: Install module dependencies
- run: composer --no-interaction --no-progress require drupal/token drupal/purge
+ run: composer --no-interaction --no-progress require drupal/token drupal/purge drupal/webform
working-directory: /var/www/drupal
# The image ships phpunit globally, but its bootstrap needs the test
diff --git a/config/install/quant.token_settings.yml b/config/install/quant.token_settings.yml
index 18104e1c..0128fe80 100644
--- a/config/install/quant.token_settings.yml
+++ b/config/install/quant.token_settings.yml
@@ -1,4 +1,4 @@
timeout: '+1 minute'
-disable: 0
+disable: false
secret: null
-strict: 0
+strict: false
diff --git a/config/install/views.view.quant_metadata_file.yml b/config/install/views.view.quant_metadata_file.yml
index d32ccffb..f43589fc 100644
--- a/config/install/views.view.quant_metadata_file.yml
+++ b/config/install/views.view.quant_metadata_file.yml
@@ -341,15 +341,6 @@ display:
published: published
revision_count: revision_count
url: url
- byte_length: 0
- highest_revision_number: 0
- md5: 0
- published_revision: 0
- redirect_http_code: 0
- redirect_url: 0
- revision_number: 0
- seq_num: 0
- type: 0
pager:
type: mini
options:
diff --git a/config/install/views.view.quant_metadata_node.yml b/config/install/views.view.quant_metadata_node.yml
index d50fdec6..495bec97 100644
--- a/config/install/views.view.quant_metadata_node.yml
+++ b/config/install/views.view.quant_metadata_node.yml
@@ -274,16 +274,6 @@ display:
date_timestamp: date_timestamp
published: published
url: url
- byte_length: 0
- highest_revision_number: 0
- md5: 0
- published_revision: 0
- redirect_http_code: 0
- redirect_url: 0
- revision_count: 0
- revision_number: 0
- seq_num: 0
- type: 0
pager:
type: full
options:
diff --git a/config/install/views.view.quant_metadata_taxonomy.yml b/config/install/views.view.quant_metadata_taxonomy.yml
index f9b5b233..fdb5d20f 100644
--- a/config/install/views.view.quant_metadata_taxonomy.yml
+++ b/config/install/views.view.quant_metadata_taxonomy.yml
@@ -408,16 +408,6 @@ display:
date_timestamp: date_timestamp
published: published
url: url
- byte_length: 0
- highest_revision_number: 0
- md5: 0
- published_revision: 0
- redirect_http_code: 0
- redirect_url: 0
- revision_count: 0
- revision_number: 0
- seq_num: 0
- type: 0
pager:
type: mini
options:
diff --git a/config/optional/views.view.quant_metadata_redirect.yml b/config/optional/views.view.quant_metadata_redirect.yml
index e67a7051..0780df70 100644
--- a/config/optional/views.view.quant_metadata_redirect.yml
+++ b/config/optional/views.view.quant_metadata_redirect.yml
@@ -412,14 +412,6 @@ display:
redirect_http_code: redirect_http_code
redirect_url: redirect_url
url: url
- byte_length: 0
- highest_revision_number: 0
- md5: 0
- published_revision: 0
- revision_count: 0
- revision_number: 0
- seq_num: 0
- type: 0
pager:
type: mini
options:
diff --git a/modules/quant_search/config/install/quant_search.entities.settings.yml b/modules/quant_search/config/install/quant_search.entities.settings.yml
index 39c0f866..af993850 100644
--- a/modules/quant_search/config/install/quant_search.entities.settings.yml
+++ b/modules/quant_search/config/install/quant_search.entities.settings.yml
@@ -1,6 +1,6 @@
-quant_search_entity_node: 1
+quant_search_entity_node: true
quant_search_title_token: '[node:title]'
quant_search_summary_token: '[node:summary]'
quant_search_content_viewmode: full
quant_search_image_token: '[node:field_image:thumbnail:url]'
-quant_search_entity_taxonomy_term: 1
+quant_search_entity_taxonomy_term: true
diff --git a/quant.install b/quant.install
index 4ce03b52..e54b7481 100644
--- a/quant.install
+++ b/quant.install
@@ -209,25 +209,69 @@ function quant_update_9008() {
* Store the on/off settings as booleans, as their schema says.
*
* Sites that installed the module on Drupal 11.1 or earlier kept 0 and 1 from
- * config/install, which fails strict config schema checks.
+ * config/install (quant.settings, quant.token_settings, and the Quant Search
+ * entity settings), which fails strict config schema checks.
*/
function quant_update_9009() {
- $config = \Drupal::configFactory()->getEditable('quant.settings');
- $keys = [
- 'quant_enabled',
- 'quant_enabled_nodes',
- 'quant_enabled_taxonomy',
- 'quant_enabled_views',
- 'quant_enabled_redirects',
- 'ssl_cert_verify',
- 'disable_content_drafts',
- 'quant_show_page_info_block',
+ $booleans = [
+ 'quant.settings' => [
+ 'quant_enabled',
+ 'quant_enabled_nodes',
+ 'quant_enabled_taxonomy',
+ 'quant_enabled_views',
+ 'quant_enabled_redirects',
+ 'ssl_cert_verify',
+ 'disable_content_drafts',
+ 'quant_show_page_info_block',
+ ],
+ 'quant.token_settings' => ['disable', 'strict'],
+ // Quant Search settings, if that submodule is on.
+ 'quant_search.entities.settings' => [
+ 'quant_search_entity_node',
+ 'quant_search_entity_taxonomy_term',
+ ],
];
- foreach ($keys as $key) {
- $value = $config->get($key);
- if ($value !== NULL && !is_bool($value)) {
- $config->set($key, (bool) $value);
+ foreach ($booleans as $name => $keys) {
+ $config = \Drupal::configFactory()->getEditable($name);
+ if ($config->isNew()) {
+ continue;
+ }
+ foreach ($keys as $key) {
+ $value = $config->get($key);
+ if ($value !== NULL && !is_bool($value)) {
+ $config->set($key, (bool) $value);
+ }
+ }
+ $config->save(TRUE);
+ }
+}
+
+/**
+ * Keep only the ticked keys in the Quant metadata Views field option.
+ *
+ * The option came from checkboxes and kept 0 for each unticked key, which
+ * fails strict config schema checks. The field reads only ticked keys, so
+ * the result is the same.
+ */
+function quant_update_9010() {
+ $factory = \Drupal::configFactory();
+ foreach ($factory->listAll('views.view.') as $name) {
+ $view = $factory->getEditable($name);
+ $changed = FALSE;
+ foreach ((array) $view->get('display') as $display_id => $display) {
+ foreach ((array) ($display['display_options']['fields'] ?? []) as $field_id => $field) {
+ if (($field['plugin_id'] ?? '') !== 'quant_metadata' || !is_array($field['quant_metadata'] ?? NULL)) {
+ continue;
+ }
+ $kept = array_filter($field['quant_metadata']);
+ if ($kept !== $field['quant_metadata']) {
+ $view->set("display.$display_id.display_options.fields.$field_id.quant_metadata", $kept);
+ $changed = TRUE;
+ }
+ }
+ }
+ if ($changed) {
+ $view->save(TRUE);
}
}
- $config->save(TRUE);
}
diff --git a/src/Plugin/views/field/QuantMetadata.php b/src/Plugin/views/field/QuantMetadata.php
index fd58ce74..d4c93b21 100644
--- a/src/Plugin/views/field/QuantMetadata.php
+++ b/src/Plugin/views/field/QuantMetadata.php
@@ -70,6 +70,21 @@ protected function defineOptions() {
return $options;
}
+ /**
+ * {@inheritdoc}
+ *
+ * Keeps only the ticked metadata keys, so the option is a list of strings,
+ * as its config schema says.
+ */
+ public function submitOptionsForm(&$form, FormStateInterface $form_state) {
+ $options = $form_state->getValue('options');
+ if (isset($options['quant_metadata']) && is_array($options['quant_metadata'])) {
+ $options['quant_metadata'] = array_filter($options['quant_metadata']);
+ $form_state->setValue('options', $options);
+ }
+ parent::submitOptionsForm($form, $form_state);
+ }
+
/**
* {@inheritdoc}
*/
diff --git a/tests/src/Kernel/SettingsBooleanTest.php b/tests/src/Kernel/SettingsBooleanTest.php
index b3063e6b..e7cdfb71 100644
--- a/tests/src/Kernel/SettingsBooleanTest.php
+++ b/tests/src/Kernel/SettingsBooleanTest.php
@@ -37,17 +37,6 @@ class SettingsBooleanTest extends KernelTestBase {
'quant_show_page_info_block',
];
- /**
- * The shipped defaults are booleans.
- */
- public function testInstallConfigIsBoolean(): void {
- $path = \Drupal::service('extension.list.module')->getPath('quant');
- $data = Yaml::decode(file_get_contents($this->root . '/' . $path . '/config/install/quant.settings.yml'));
- foreach (self::KEYS as $key) {
- $this->assertIsBool($data[$key], "$key is a boolean in config/install.");
- }
- }
-
/**
* The update casts integers that an older install stored.
*/
@@ -68,6 +57,41 @@ public function testUpdateCastsStoredIntegers(): void {
}
$this->assertTrue($settings->get('quant_enabled'));
$this->assertFalse($settings->get('disable_content_drafts'));
+
+ // The token settings too.
+ $this->container->get('config.storage')->write('quant.token_settings', [
+ 'disable' => 0,
+ 'strict' => 1,
+ 'secret' => 's',
+ 'timeout' => '+1 minute',
+ ]);
+ $this->container->get('config.factory')->reset('quant.token_settings');
+ quant_update_9009();
+ $this->assertFalse($this->config('quant.token_settings')->get('disable'));
+ $this->assertTrue($this->config('quant.token_settings')->get('strict'));
+ }
+
+ /**
+ * The update drops the unticked keys of the Quant metadata Views option.
+ */
+ public function testUpdateFiltersTheViewsOption(): void {
+ $name = 'views.view.quant_metadata_node';
+ $path = 'display.default.display_options.fields.quant_metadata.quant_metadata';
+ $file = $this->root . '/' . \Drupal::service('extension.list.module')->getPath('quant') . "/config/install/$name.yml";
+ $view = Yaml::decode(file_get_contents($file));
+ $option = &$view['display']['default']['display_options']['fields']['quant_metadata']['quant_metadata'];
+ $option['md5'] = 0;
+ $option['seq_num'] = 0;
+ $this->container->get('config.storage')->write($name, $view);
+ $this->container->get('config.factory')->reset($name);
+
+ \Drupal::moduleHandler()->loadInclude('quant', 'install');
+ quant_update_9010();
+
+ $kept = $this->config($name)->get($path);
+ $this->assertArrayNotHasKey('md5', $kept);
+ $this->assertArrayNotHasKey('seq_num', $kept);
+ $this->assertSame('url', $kept['url']);
}
}
diff --git a/tests/src/Kernel/ShippedConfigSchemaTest.php b/tests/src/Kernel/ShippedConfigSchemaTest.php
new file mode 100644
index 00000000..d0e529bf
--- /dev/null
+++ b/tests/src/Kernel/ShippedConfigSchemaTest.php
@@ -0,0 +1,90 @@
+root . '/' . \Drupal::service('extension.list.module')->getPath('quant');
+ $files = glob($root . '{,/modules/*}/config/{install,optional}/*.yml', GLOB_BRACE);
+ $this->assertNotEmpty($files);
+
+ $typed = \Drupal::service('config.typed');
+ $handler = \Drupal::moduleHandler();
+ $errors = [];
+ $checked = 0;
+ foreach ($files as $file) {
+ $name = basename($file, '.yml');
+ $data = Yaml::decode(file_get_contents($file));
+ // Optional config whose modules are not here would not be installed.
+ $needs = $data['dependencies']['module'] ?? [];
+ if (array_filter($needs, fn ($module) => !$handler->moduleExists($module))) {
+ continue;
+ }
+ $result = $this->checkConfigSchema($typed, $name, $data);
+ $checked++;
+ if ($result === FALSE) {
+ $errors[] = "$name: no schema";
+ }
+ elseif (is_array($result)) {
+ foreach ($result as $key => $error) {
+ $errors[] = "$key: $error";
+ }
+ }
+ }
+ $this->assertGreaterThanOrEqual(9, $checked, 'The shipped config files were checked.');
+ $this->assertSame([], $errors, "Shipped config that does not match its schema:\n" . implode("\n", $errors));
+ }
+
+}