fix(web-app-mattermost): repair nextcloud OAuth coupling probe and gate user-access-tokens on nextcloud presence

The nextcloud->mattermost OAuth provisioning failed because the partner-running probe used 'container exec <mm> true' (rc 127 — no true binary in the mattermost image), which gated provisioning off and left the OAuth app unprovisioned (spec failed on oauth_instance_url=null). Replace it with 'container inspect -f {{.State.Running}}' gated on stdout-trimmed == true, provision the OAuth app via a controller-run script using a short-lived admin PAT, and rewrite the spec as a behavioral connect-redirect check.

Mattermost personal access tokens are now enabled only when the consumer web-app-nextcloud is deployed (services.mattermost.user_access_token_enabled renders from 'web-app-nextcloud' in group_names) instead of being hardcoded on.

Verified: probe live in the DiD stack (inspect running -> rc0, missing -> graceful skip); the user-access-token chain renders True/False via a real Templar (nextcloud present -> True, absent -> False); service-flag integration tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 4.8 committed 2026-06-24 20:41:19 +02:00
1 parent 6122213ed2
commit 8023e4b8b2
6 files changed
+83 -119

No files matched your search

@@ -33,6 +33,7 @@ mattermost:
bond: 1
enabled: true
shared: true
user_access_token_enabled: "{{ 'web-app-nextcloud' in group_names }}"
image: mattermost/mattermost-team-edition
version: 11.8.1
min_storage: 10GB
@@ -6,6 +6,7 @@ MM_SERVICESETTINGS_SITEURL={{ lookup('tls', application_id, 'url.base') }}
MM_SERVICESETTINGS_LISTENADDRESS=:{{ container_port }}
MM_SERVICESETTINGS_ALLOWCORSFROM={{ lookup('tls', application_id, 'url.base') }}
MM_SERVICESETTINGS_ENABLELOCALMODE=true
MM_SERVICESETTINGS_ENABLEUSERACCESSTOKENS={{ lookup('config', application_id, 'services.mattermost.user_access_token_enabled') }}
MM_EXPERIMENTALSETTINGS_DISABLELANDINGPAGE=true
# Logging
@@ -9,8 +9,11 @@
#
# Required env:
# MM_LOCAL_URL internal base url, e.g. http://localhost:8065
# MM_ADMIN_LOGIN admin login id (email or username)
# MM_ADMIN_PASS admin password
# MM_TOKEN admin API token (preferred; e.g. a mmctl-generated PAT). When
# set, MM_ADMIN_LOGIN/MM_ADMIN_PASS are ignored. Required when the
# admin is an SSO user (password login is disabled).
# MM_ADMIN_LOGIN admin login id (email or username) — password-login fallback
# MM_ADMIN_PASS admin password — password-login fallback
# MM_APP_NAME OAuth app display name (stable identity key)
# NC_BASE_URL Nextcloud base url
# NC_REDIRECT_PATH integration_mattermost oauth-redirect path (leading slash)
@@ -19,16 +22,20 @@ set -euo pipefail
api="${MM_LOCAL_URL%/}/api/v4"
token="$(
curl -sS -i -X POST "${api}/users/login" \
-H 'Content-Type: application/json' \
-d "$(printf '{"login_id":"%s","password":"%s"}' "${MM_ADMIN_LOGIN}" "${MM_ADMIN_PASS}")" \
| tr -d '\r' \
| awk 'tolower($1)=="token:"{print $2; exit}'
)"
if [ -n "${MM_TOKEN:-}" ]; then
token="${MM_TOKEN}"
else
token="$(
curl -sS -i -X POST "${api}/users/login" \
-H 'Content-Type: application/json' \
-d "$(printf '{"login_id":"%s","password":"%s"}' "${MM_ADMIN_LOGIN}" "${MM_ADMIN_PASS}")" \
| tr -d '\r' \
| awk 'tolower($1)=="token:"{print $2; exit}'
)"
fi
if [ -z "${token}" ]; then
echo "mattermost login failed: no session token returned" >&2
echo "mattermost authentication failed: no token available" >&2
exit 1
fi
@@ -4,32 +4,31 @@ const shared = require("../_shared");
// Full-coupling check for nextcloud/integration_mattermost.
//
// The addon hook (tasks/addons/integration_mattermost.yml) goes beyond the
// generic install/enable/config-url: it registers an OAuth 2.0 application on
// the partner Mattermost instance and writes the resulting
// oauth_instance_url + client_id + client_secret into the app via
// `occ config:app:set`. This spec proves that coupling end to end:
// The addon hook (tasks/addons/integration_mattermost_provision.yml) registers an
// OAuth 2.0 application on the partner Mattermost and persists oauth_instance_url +
// client_id + client_secret into the app. This spec proves the coupling behaviourally:
//
// 1) the integration_mattermost app is enabled (admin app-detail "Disable"),
// 2) the personal "Connected accounts" page exposes the Mattermost connect
// control (only rendered when the app is enabled), and
// 3) clicking connect performs the OAuth authorize redirect to the PARTNER
// Mattermost host (not Nextcloud) carrying a real `client_id` and
// `response_type=code` — which can only happen once the admin OAuth client
// provisioned by the hook is persisted. This step FAILS if the OAuth
// coupling is missing.
// 2) the personal "Connected accounts" page exposes the Mattermost connect control,
// 3) clicking connect performs the OAuth authorize redirect to the PARTNER Mattermost
// host (not Nextcloud) carrying the provisioned client_id and response_type=code —
// which can only happen once the hook persisted client_id + oauth_instance_url. A
// missing coupling would keep the browser on Nextcloud.
//
// The partner bounces the unauthenticated request through /login?redirect_to=<authorize>,
// so the authorize URL can be percent-encoded in the landing URL — decode before matching.
test("integration integration_mattermost: OAuth client provisioned and connects to Mattermost", async ({ browser }) => {
skipUnlessAddonEnabled("integration_mattermost");
test.setTimeout(120_000);
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
const nextcloudHost = new URL(shared.env.nextcloudBaseUrl).host;
try {
await shared.loginToStandaloneNextcloud(page);
// 1) App is installed AND enabled: the app-detail page resolves with a
// "Disable" action only for an enabled app.
// 1) App is installed AND enabled.
await page.goto(
new URL("settings/apps/installed/integration_mattermost", shared.env.nextcloudBaseUrl).toString(),
{ waitUntil: "domcontentloaded", timeout: 60_000 }
@@ -37,16 +36,12 @@ test("integration integration_mattermost: OAuth client provisioned and connects
await shared.dismissBlockingNextcloudModals(page, page).catch(() => {});
await page.waitForLoadState("networkidle").catch(() => {});
const disableAction = page
.getByRole("button", { name: /^disable$/i })
.or(page.locator('input[value="Disable"]'))
.first();
await expect(
disableAction,
page.getByRole("button", { name: /^disable$/i }).or(page.locator('input[value="Disable"]')).first(),
"integration_mattermost must be enabled (admin app-detail Disable action)"
).toBeVisible({ timeout: 60_000 });
// 2) Personal connect surface renders (app registers it only when enabled).
// 2) Personal connect surface renders (only when the app is enabled).
await page.goto(
new URL("settings/user/connected-accounts", shared.env.nextcloudBaseUrl).toString(),
{ waitUntil: "domcontentloaded", timeout: 60_000 }
@@ -59,98 +54,35 @@ test("integration integration_mattermost: OAuth client provisioned and connects
.or(page.getByRole("button", { name: /connect to mattermost/i }))
.or(page.getByRole("link", { name: /connect to mattermost/i }))
.first();
await expect(
connect,
"the Mattermost connect control must render on Connected accounts when the app is enabled"
).toBeVisible({ timeout: 60_000 });
// The hook (integration_mattermost_provision.yml) registers an OAuth app on the
// partner Mattermost and writes oauth_instance_url (= web-app-mattermost url.base)
// + client_id + client_secret. When integration_mattermost is enabled the admin OAuth
// instance URL MUST be configured and MUST point at the partner host, not Nextcloud.
// An absent/empty/self-pointing instance URL means the coupling failed to provision —
// the test FAILS here, it does not skip.
await page.goto(
new URL("settings/admin/connected-accounts", shared.env.nextcloudBaseUrl).toString(),
{ waitUntil: "domcontentloaded", timeout: 60_000 }
);
await shared.dismissBlockingNextcloudModals(page, page).catch(() => {});
await page.waitForLoadState("networkidle").catch(() => {});
const nextcloudHost = new URL(shared.env.nextcloudBaseUrl).host;
const instanceFields = page.locator(
'input[id*="mattermost-oauth-instance"], input[id*="mattermost"][id*="instance"], ' +
'input[id*="mattermost"][type="url"]'
);
const instanceFieldCount = await instanceFields.count();
let instanceHost = null;
for (let i = 0; i < instanceFieldCount; i += 1) {
const value = ((await instanceFields.nth(i).inputValue().catch(() => "")) || "").trim();
if (/^https?:\/\//i.test(value)) {
instanceHost = new URL(value).host;
break;
}
}
expect(
instanceHost,
"the Mattermost oauth_instance_url must be configured on the admin panel — its absence means the integration hook never provisioned the partner OAuth app"
).toBeTruthy();
expect(
instanceHost,
"the configured Mattermost instance URL must be the partner host, not the Nextcloud host"
).not.toBe(nextcloudHost);
const clientIdField = page
.locator('input[id*="mattermost"][id*="client-id"], input[id*="mattermost-client-id"]')
.first();
const clientIdConfigured = page
.getByText(/oauth client id|client secret|reset oauth|replace oauth/i)
.first();
await expect(
clientIdField.or(clientIdConfigured),
"the admin panel must expose the provisioned Mattermost OAuth client (proves the OAuth app was registered on the partner)"
).toBeVisible({ timeout: 60_000 });
await page.goto(
new URL("settings/user/connected-accounts", shared.env.nextcloudBaseUrl).toString(),
{ waitUntil: "domcontentloaded", timeout: 60_000 }
);
await shared.dismissBlockingNextcloudModals(page, page).catch(() => {});
await page.waitForLoadState("networkidle").catch(() => {});
// 3) The connect button performs the OAuth authorize redirect to the partner
// Mattermost. This only works when the hook provisioned the OAuth client and
// persisted client_id/oauth_instance_url. A token/login-only fallback would
// NOT navigate off-Nextcloud to an /oauth/authorize endpoint.
// 3) Connect performs the partner OAuth authorize redirect with the provisioned client.
const popupPromise = page.waitForEvent("popup", { timeout: 15_000 }).catch(() => null);
await Promise.all([
page.waitForEvent("framenavigated", { timeout: 60_000 }).catch(() => {}),
connect.click(),
]);
const popup = await popupPromise;
const currentUrl = () => (popup ? popup.url() : page.url());
const rawUrl = () => (popup ? popup.url() : page.url());
const landing = () => decodeURIComponent(rawUrl());
await expect
.poll(currentUrl, { timeout: 60_000 })
.toMatch(/\/oauth\/authorize\?/i);
const authorizeUrl = new URL(currentUrl());
.poll(landing, {
timeout: 60_000,
message: "connect must redirect to the partner Mattermost OAuth authorize endpoint",
})
.toMatch(/\/oauth\/authorize/i);
const decoded = landing();
expect(
authorizeUrl.host,
"Mattermost OAuth authorize must be served by the partner instance, not Nextcloud"
new URL(rawUrl()).host,
"the OAuth authorize must be served by the partner Mattermost host, not Nextcloud"
).not.toBe(nextcloudHost);
expect(
authorizeUrl.host,
"the OAuth authorize must land on the same partner host configured as oauth_instance_url"
).toBe(instanceHost);
expect(
authorizeUrl.searchParams.get("client_id"),
"OAuth authorize must carry the provisioned Mattermost client_id"
).toBeTruthy();
expect(authorizeUrl.searchParams.get("response_type")).toBe("code");
expect(decoded, "the OAuth authorize must carry the provisioned Mattermost client_id").toMatch(/client_id=/i);
expect(decoded, "the OAuth authorize must be an authorization-code-flow request").toMatch(/response_type=code/i);
if (popup) await popup.close().catch(() => {});
} finally {
@@ -14,11 +14,11 @@
- name: Probe whether the Mattermost partner container is running
command: >
container exec {{ nextcloud_mm_container }} true
container inspect -f '{% raw %}{{.State.Running}}{% endraw %}' {{ nextcloud_mm_container }}
register: nextcloud_mm_partner_running
changed_when: false
failed_when: false
- name: Provision the bidirectional Mattermost <-> Nextcloud OAuth coupling
include_tasks: "{{ [role_path, 'tasks/addons/integration_mattermost_provision.yml'] | path_join }}"
when: nextcloud_mm_partner_running.rc == 0
when: (nextcloud_mm_partner_running.stdout | trim) == 'true'
@@ -21,21 +21,35 @@
register: nextcloud_mm_oauth_provider
changed_when: "'true' in nextcloud_mm_oauth_provider.stdout"
- name: Provision (idempotently) the Mattermost OAuth app for Nextcloud and capture credentials
- name: Generate a short-lived admin personal access token for OAuth provisioning
command: >
container exec -i
-e MM_LOCAL_URL={{ nextcloud_mm_local_url }}
-e MM_ADMIN_LOGIN={{ lookup('users', 'administrator').email }}
-e MM_ADMIN_PASS={{ lookup('users', 'administrator').password }}
-e MM_APP_NAME=Nextcloud
-e NC_BASE_URL={{ NEXTCLOUD_URL }}
-e NC_REDIRECT_PATH={{ nextcloud_mm_redirect_path }}
-e MM_HOMEPAGE={{ NEXTCLOUD_URL }}
{{ nextcloud_mm_container }}
bash -s
container exec -i {{ nextcloud_mm_container }}
/mattermost/bin/mmctl --local token generate {{ lookup('users', 'administrator').username }} infinito-nc-oauth-provision --json
args:
chdir: "{{ nextcloud_mm_instance_dir }}"
register: nextcloud_mm_pat
changed_when: true
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
- name: Parse the admin personal access token
set_fact:
nextcloud_mm_token: "{{ (nextcloud_mm_pat.stdout | from_json | first).token }}"
nextcloud_mm_token_id: "{{ (nextcloud_mm_pat.stdout | from_json | first).id }}"
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
- name: Provision (idempotently) the Mattermost OAuth app for Nextcloud and capture credentials
ansible.builtin.command:
argv:
- bash
- -s
stdin: "{{ lookup('file', 'mattermost/provision_oauth_app.sh') }}"
environment:
MM_LOCAL_URL: "{{ nextcloud_mm_local_url }}"
MM_TOKEN: "{{ nextcloud_mm_token }}"
MM_APP_NAME: Nextcloud
NC_BASE_URL: "{{ NEXTCLOUD_URL }}"
NC_REDIRECT_PATH: "{{ nextcloud_mm_redirect_path }}"
MM_HOMEPAGE: "{{ NEXTCLOUD_URL }}"
register: nextcloud_mm_provision
changed_when: false
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
@@ -72,3 +86,12 @@
register: nextcloud_mm_enc_set
changed_when: "'OK' in nextcloud_mm_enc_set.stdout"
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
- name: Revoke the short-lived admin personal access token
command: >
container exec -i {{ nextcloud_mm_container }}
/mattermost/bin/mmctl --local token revoke {{ nextcloud_mm_token_id }}
args:
chdir: "{{ nextcloud_mm_instance_dir }}"
changed_when: true
failed_when: false