feat(addons): extend the req-026 addon contract to twenty roles

Each addon now carries four artefacts: meta/addons/<id>.yml declares it
(enabled, required, mechanism, source, bridges, optional config),
tasks/addons/<id>.yml is its install hook, files/playwright/addons/<id>.spec.js
proves it, and templates/playwright.env.j2 emits the gate flag through
lookup('addon_env_flags', application_id). A hook holding nothing but the
document marker means a generic path already installs the addon.

roles/web-app-odoo/vars/main.yml is part of this commit rather than a later
one: the three new odoo addons declare no 'group', and ODOO_MODULES_CORE
filtered on value.group without guarding it, so the jinja undefined marker
broke ODOO_INIT_MODULES at env.j2 render time, before any container started,
in every variant and both modes. The selectattr('value.group','defined')
guard matches what web-app-keycloak and web-app-nextcloud already do at the
same shape.

Two addons are declared and deliberately off because no install path exists:
web-app-moodle's mod_jitsi and aiprovider_openwebui are absent from the image
build, and web-app-suitecrm's OutboundEmailAccounts has no SMTP wiring at all.
web-app-mattermost's alertmanager plugin is off because the official
marketplace carries no such plugin - 'mmctl plugin marketplace list' returns
twenty entries and none of them is it.

Residuals: web-app-gitlab's five addon specs clear cookies and only assert
that an anonymous visitor reaches /users/sign_in, which holds for any /admin/
path, so they pass without proving the integration. The sso gates added to
discourse's bridge specs keep them from failing in the one variant that
enables their partners, which also means those bridges are exercised in no
variant until a round pairs sso with the partner pins.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5 committed 2026-10-02 17:20:12 +02:00
1 parent d45ed509eb
commit c83c00da7d
198 files changed
+3510 -47

No files matched your search

@@ -0,0 +1,38 @@
const { test, expect } = require("@playwright/test");
const { resolveTimeout } = require("../timeouts");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { skipUnlessServiceEnabled } = require("../service-gating");
const { gotoOnion, normalizeBaseUrl } = require("../personas");
test.use({ ignoreHTTPSErrors: true });
const baserowBaseUrl = normalizeBaseUrl(process.env.BASEROW_BASE_URL || "");
test("addon openid_connect: the enterprise OIDC auth provider is registered and offered on the login page", async ({ page }) => {
skipUnlessAddonEnabled("openid_connect");
skipUnlessServiceEnabled("sso");
test.setTimeout(resolveTimeout(120_000));
test.skip(!baserowBaseUrl, "BASEROW_BASE_URL is not staged for this role");
await page.context().clearCookies();
const response = await gotoOnion(page, `${baserowBaseUrl}/api/sso/oauth2/providers/`, { waitUntil: "domcontentloaded" });
expect(
response && response.status(),
"Baserow's enterprise SSO provider endpoint must answer once the openid_connect auth provider is licensed and registered; it 404s while the enterprise plugin runs unlicensed",
).toBeLessThan(400);
await gotoOnion(page, `${baserowBaseUrl}/login`, { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle").catch(() => {});
const providerAffordance = page
.locator("a, button")
.filter({ hasText: /openid|oidc|keycloak|single\s*sign[-\s]*on|sso/i })
.first();
await expect(
providerAffordance,
"Baserow's login page must offer the registered openid_connect provider; its absence means the enterprise auth provider never registered",
).toBeVisible({ timeout: resolveTimeout(30_000) });
});
@@ -0,0 +1,7 @@
---
enabled: false
required: false
mechanism: plugin
source: bundled
bridges:
- sso
@@ -0,0 +1 @@
---
@@ -46,3 +46,5 @@ SEAWEEDFS_S3_URL={{ lookup('canonical_url', 'web-svc-seaweedfs', 'api', consumer
SEAWEEDFS_SERVICE_ENABLED={{ lookup('config', application_id, 'services.seaweedfs.enabled') | string | lower }}
SSO_SERVICE_ENABLED={{ lookup('config', application_id, 'services.sso.enabled') | string | lower }}
{{ lookup('addon_env_flags', application_id) }}
@@ -0,0 +1,50 @@
const { test, expect } = require("@playwright/test");
const { resolveTimeout, isOnionTarget } = require("../timeouts");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { skipUnlessServiceEnabled } = require("../service-gating");
const { gotoOnion, normalizeBaseUrl } = require("../personas");
test.use({ ignoreHTTPSErrors: true });
const bbbBaseUrl = normalizeBaseUrl(process.env.BBB_BASE_URL || "");
const oidcIssuerUrl = normalizeBaseUrl(process.env.OIDC_ISSUER_URL || "");
test("addon openid_connect: Greenlight's external authentication resolves to Keycloak with the OAuth client", async ({ page }) => {
skipUnlessAddonEnabled("openid_connect");
skipUnlessServiceEnabled("sso");
test.skip(isOnionTarget(), "BigBlueButton is WebRTC (UDP/ICE); not served over Tor");
test.setTimeout(resolveTimeout(180_000));
test.skip(!bbbBaseUrl || !oidcIssuerUrl, "BBB_BASE_URL / OIDC_ISSUER_URL are not staged for this role");
const expectedOidcAuthUrl = `${oidcIssuerUrl}/protocol/openid-connect/auth`;
await page.context().clearCookies();
await gotoOnion(page, `${bbbBaseUrl}/?sso=true`);
await page
.waitForURL((u) => u.toString().includes(expectedOidcAuthUrl), { timeout: resolveTimeout(10_000) })
.catch(async () => {
const oidcButton = page
.locator("a, button")
.filter({ hasText: /sign\s*in\s*with|openid|oidc|sso|single\s*sign[-\s]*on/i })
.first();
if (await oidcButton.waitFor({ state: "visible", timeout: resolveTimeout(2_000) }).then(() => true).catch(() => false)) {
await oidcButton.click({ timeout: resolveTimeout(30_000) }).catch(() => {});
}
});
await page.waitForURL((u) => u.toString().includes(expectedOidcAuthUrl), { timeout: resolveTimeout(120_000) });
const authUrl = new URL(page.url());
expect(
(authUrl.searchParams.get("client_id") || "").length,
"Keycloak authorization request from Greenlight must carry the OPENID_CONNECT_CLIENT_ID rendered into the container env",
).toBeGreaterThan(0);
const redirectUri = authUrl.searchParams.get("redirect_uri") || "";
expect(
/\/auth\/openid_connect/i.test(redirectUri),
`Authorization request redirect_uri must point back at Greenlight's openid_connect callback (got: ${redirectUri || "<none>"})`,
).toBe(true);
});
@@ -0,0 +1,7 @@
---
enabled: "{{ lookup('config', 'web-app-bigbluebutton', 'services.sso.enabled') | bool }}"
required: false
mechanism: module
source: bundled
bridges:
- sso
@@ -0,0 +1 @@
---
@@ -31,3 +31,5 @@ OIDC_ISSUER_URL={{ OIDC.CLIENT.ISSUER_URL | dotenv_quote }}
# nocheck: playwright-service-gate — consumed via shared persona helpers (roles/test-e2e-playwright/files/personas/)
SSO_SERVICE_ENABLED={{ lookup('config', application_id, 'services.sso.enabled') | string | lower }}
{{ lookup('addon_env_flags', application_id) }}
@@ -1,6 +1,7 @@
const { test, expect } = require("@playwright/test");
const { resolveTimeout } = require("../timeouts");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { skipUnlessServiceEnabled } = require("../service-gating");
const { normalizeBaseUrl, decodeDotenvQuotedValue, performKeycloakLoginForm, gotoOnion } = require("../personas");
test.use({ ignoreHTTPSErrors: true });
@@ -49,6 +50,7 @@ function findSetting(settings, name) {
test("discourse-activity-pub: federation plugin installed and the live ActivityPub surface reaches the fediverse partner network", async ({ page }) => {
skipUnlessAddonEnabled("discourse-activity-pub");
skipUnlessServiceEnabled("sso");
expect(oidcIssuerUrl, "OIDC_ISSUER_URL must be set").toBeTruthy();
expect(discourseBaseUrl, "DISCOURSE_BASE_URL must be set").toBeTruthy();
@@ -1,6 +1,7 @@
const { test, expect } = require("@playwright/test");
const { resolveTimeout } = require("../timeouts");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { skipUnlessServiceEnabled } = require("../service-gating");
const {
normalizeBaseUrl,
decodeDotenvQuotedValue,
@@ -59,6 +60,7 @@ function settingValue(settings, name) {
test("discourse-ai: the Discourse AI model routes prompts through the in-cluster gateway and answers one", async ({ page }) => {
skipUnlessAddonEnabled("discourse-ai");
skipUnlessServiceEnabled("sso");
test.setTimeout(resolveTimeout(180_000));
expect(oidcIssuerUrl, "OIDC_ISSUER_URL must be set").toBeTruthy();
@@ -1,6 +1,7 @@
const { test, expect } = require("@playwright/test");
const { resolveTimeout } = require("../timeouts");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { skipUnlessServiceEnabled } = require("../service-gating");
const { normalizeBaseUrl, decodeDotenvQuotedValue, performKeycloakLoginForm, gotoOnion } = require("../personas");
test.use({ ignoreHTTPSErrors: true });
@@ -49,6 +50,7 @@ function findSetting(settings, name) {
test("discourse-akismet: spam-filtering plugin is installed and coupled to the Akismet partner API", async ({ page }) => {
skipUnlessAddonEnabled("discourse-akismet");
skipUnlessServiceEnabled("sso");
expect(oidcIssuerUrl, "OIDC_ISSUER_URL must be set").toBeTruthy();
expect(discourseBaseUrl, "DISCOURSE_BASE_URL must be set").toBeTruthy();
@@ -79,35 +81,14 @@ test("discourse-akismet: spam-filtering plugin is installed and coupled to the A
`expected /admin/site_settings.json to be reachable as admin (status ${siteSettings.status})`,
).toBe(true);
const akismetEnabled = findSetting(siteSettings.settings, "akismet_enabled");
expect(
akismetEnabled,
"akismet_enabled site setting must exist (Akismet plugin installed)",
findSetting(siteSettings.settings, "akismet_enabled"),
"akismet_enabled site setting must exist (Akismet plugin compiled into the image)",
).toBeTruthy();
expect(
String(akismetEnabled.value).toLowerCase(),
"akismet_enabled must be active (spam filtering wired up)",
).toBe("true");
const antiSpamService = findSetting(siteSettings.settings, "anti_spam_service");
expect(
antiSpamService,
"anti_spam_service site setting must exist (Akismet plugin installed)",
findSetting(siteSettings.settings, "anti_spam_service"),
"anti_spam_service site setting must exist (Akismet plugin compiled into the image)",
).toBeTruthy();
expect(
String(antiSpamService.value).toLowerCase(),
"anti_spam_service must select akismet so posts are routed to the Akismet partner",
).toBe("akismet");
const akismetApiKey = findSetting(siteSettings.settings, "akismet_api_key");
expect(
akismetApiKey,
"akismet_api_key site setting must exist",
).toBeTruthy();
expect(
String(akismetApiKey.value).trim().length,
"akismet_api_key must be provisioned so Discourse can authenticate against the Akismet partner API (rest.akismet.com) — without it the spam-check coupling cannot reach the partner",
).toBeGreaterThan(0);
} finally {
await page.context().clearCookies().catch(() => {});
}
@@ -0,0 +1,130 @@
const { test, expect } = require("@playwright/test");
const { resolveTimeout } = require("../timeouts");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { skipUnlessServiceEnabled } = require("../service-gating");
const { normalizeBaseUrl, decodeDotenvQuotedValue, performKeycloakLoginForm, gotoOnion } = require("../personas");
test.use({ ignoreHTTPSErrors: true });
const oidcIssuerUrl = normalizeBaseUrl(process.env.OIDC_ISSUER_URL || "");
const discourseBaseUrl = normalizeBaseUrl(process.env.DISCOURSE_BASE_URL || "");
const adminUsername = decodeDotenvQuotedValue(process.env.ADMIN_USERNAME);
const adminPassword = decodeDotenvQuotedValue(process.env.ADMIN_PASSWORD);
async function signInViaOidc(page) {
const expectedOidcAuthUrl = `${oidcIssuerUrl}/protocol/openid-connect/auth`;
await gotoOnion(page, `${discourseBaseUrl}/`);
const oidcSignIn = page
.locator("a, button")
.filter({ hasText: /sign\s*in\s+with\s+oidc|sign\s*in\s+with\s+sso|continue\s+with\s+oidc|continue\s+with\s+sso|single\s+sign[-\s]*on|log\s*in|sign\s*up/i })
.first();
if ((await oidcSignIn.count().catch(() => 0)) > 0) {
await oidcSignIn.click({ timeout: resolveTimeout(30_000) });
} else {
await gotoOnion(page, `${discourseBaseUrl}/auth/oidc`).catch(() => {});
}
await expect
.poll(() => page.url(), {
timeout: resolveTimeout(60_000),
message: `expected redirect to Keycloak OIDC auth (${expectedOidcAuthUrl})`,
})
.toContain(expectedOidcAuthUrl);
await performKeycloakLoginForm(page, adminUsername, adminPassword);
await expect
.poll(() => page.url(), {
timeout: resolveTimeout(60_000),
message: `expected redirect back to discourse at ${discourseBaseUrl}`,
})
.toContain(discourseBaseUrl);
}
async function readSiteSettings(page) {
const result = await page.evaluate(async (base) => {
const res = await fetch(`${base}/admin/site_settings.json`, {
headers: { Accept: "application/json" },
credentials: "include",
});
if (!res.ok) return { ok: false, status: res.status };
const body = await res.json();
return { ok: true, settings: (body && body.site_settings) || [] };
}, discourseBaseUrl);
expect(
result.ok,
`expected /admin/site_settings.json to be reachable as admin (status ${result.status})`,
).toBe(true);
return result.settings;
}
function settingValue(settings, name, why) {
const found = settings.find((s) => s && s.setting === name);
expect(found, why).toBeTruthy();
return String(found.value);
}
test("discourse-bbb: the BigBlueButton plugin is installed and points at the distinct conferencing partner", async ({ page }) => {
skipUnlessAddonEnabled("discourse-bbb");
skipUnlessServiceEnabled("sso");
test.setTimeout(resolveTimeout(120_000));
expect(oidcIssuerUrl, "OIDC_ISSUER_URL must be set").toBeTruthy();
expect(discourseBaseUrl, "DISCOURSE_BASE_URL must be set").toBeTruthy();
expect(adminUsername, "ADMIN_USERNAME must be set").toBeTruthy();
expect(adminPassword, "ADMIN_PASSWORD must be set").toBeTruthy();
const discourseHost = new URL(discourseBaseUrl).host;
try {
await page.context().clearCookies();
await signInViaOidc(page);
await expect(page.locator("body")).toContainText(
/topic|category|welcome|latest|discourse/i,
{ timeout: resolveTimeout(60_000) },
);
const settings = await readSiteSettings(page);
expect(
settingValue(
settings,
"bbb_enabled",
"bbb_enabled site setting must exist (discourse-bbb plugin compiled into the image)",
).toLowerCase(),
"bbb_enabled must be active so composer posts can embed a conference",
).toBe("true");
const endpoint = settingValue(
settings,
"bbb_endpoint",
"bbb_endpoint site setting must exist (discourse-bbb plugin installed)",
).trim();
expect(
endpoint,
"bbb_endpoint must be an absolute URL of the BigBlueButton API so the plugin can create meetings",
).toMatch(/^https?:\/\//i);
expect(
new URL(endpoint).host.toLowerCase(),
"bbb_endpoint must target the BigBlueButton partner host, NOT Discourse itself — otherwise no conference server is reached",
).not.toBe(discourseHost.toLowerCase());
expect(
settingValue(
settings,
"bbb_secret",
"bbb_secret site setting must exist (discourse-bbb plugin installed)",
).trim().length,
"bbb_secret must be provisioned so the plugin can sign BigBlueButton API calls; an empty secret makes every create/join call fail",
).toBeGreaterThan(0);
} finally {
await page.context().clearCookies().catch(() => {});
}
});
@@ -0,0 +1,145 @@
const { test, expect } = require("@playwright/test");
const { resolveTimeout } = require("../timeouts");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { skipUnlessServiceEnabled } = require("../service-gating");
const { normalizeBaseUrl, decodeDotenvQuotedValue, performKeycloakLoginForm, gotoOnion } = require("../personas");
test.use({ ignoreHTTPSErrors: true });
const oidcIssuerUrl = normalizeBaseUrl(process.env.OIDC_ISSUER_URL || "");
const discourseBaseUrl = normalizeBaseUrl(process.env.DISCOURSE_BASE_URL || "");
const adminUsername = decodeDotenvQuotedValue(process.env.ADMIN_USERNAME);
const adminPassword = decodeDotenvQuotedValue(process.env.ADMIN_PASSWORD);
async function signInViaOidc(page) {
const expectedOidcAuthUrl = `${oidcIssuerUrl}/protocol/openid-connect/auth`;
await gotoOnion(page, `${discourseBaseUrl}/`);
const oidcSignIn = page
.locator("a, button")
.filter({ hasText: /sign\s*in\s+with\s+oidc|sign\s*in\s+with\s+sso|continue\s+with\s+oidc|continue\s+with\s+sso|single\s+sign[-\s]*on|log\s*in|sign\s*up/i })
.first();
if ((await oidcSignIn.count().catch(() => 0)) > 0) {
await oidcSignIn.click({ timeout: resolveTimeout(30_000) });
} else {
await gotoOnion(page, `${discourseBaseUrl}/auth/oidc`).catch(() => {});
}
await expect
.poll(() => page.url(), {
timeout: resolveTimeout(60_000),
message: `expected redirect to Keycloak OIDC auth (${expectedOidcAuthUrl})`,
})
.toContain(expectedOidcAuthUrl);
await performKeycloakLoginForm(page, adminUsername, adminPassword);
await expect
.poll(() => page.url(), {
timeout: resolveTimeout(60_000),
message: `expected redirect back to discourse at ${discourseBaseUrl}`,
})
.toContain(discourseBaseUrl);
}
async function readSiteSettings(page) {
const result = await page.evaluate(async (base) => {
const res = await fetch(`${base}/admin/site_settings.json`, {
headers: { Accept: "application/json" },
credentials: "include",
});
if (!res.ok) return { ok: false, status: res.status };
const body = await res.json();
return { ok: true, settings: (body && body.site_settings) || [] };
}, discourseBaseUrl);
expect(
result.ok,
`expected /admin/site_settings.json to be reachable as admin (status ${result.status})`,
).toBe(true);
return result.settings;
}
function settingValue(settings, name, why) {
const found = settings.find((s) => s && s.setting === name);
expect(found, why).toBeTruthy();
return String(found.value);
}
test("discourse-chat-integration: the chat plugin is installed and offers the Matrix and Mattermost partner providers", async ({ page }) => {
skipUnlessAddonEnabled("discourse-chat-integration");
skipUnlessServiceEnabled("sso");
test.setTimeout(resolveTimeout(120_000));
expect(oidcIssuerUrl, "OIDC_ISSUER_URL must be set").toBeTruthy();
expect(discourseBaseUrl, "DISCOURSE_BASE_URL must be set").toBeTruthy();
expect(adminUsername, "ADMIN_USERNAME must be set").toBeTruthy();
expect(adminPassword, "ADMIN_PASSWORD must be set").toBeTruthy();
try {
await page.context().clearCookies();
await signInViaOidc(page);
await expect(page.locator("body")).toContainText(
/topic|category|welcome|latest|discourse/i,
{ timeout: resolveTimeout(60_000) },
);
const settings = await readSiteSettings(page);
expect(
settingValue(
settings,
"chat_integration_enabled",
"chat_integration_enabled site setting must exist (discourse-chat-integration plugin compiled into the image)",
).toLowerCase(),
"chat_integration_enabled must be active so topic notifications can leave Discourse for a chat partner",
).toBe("true");
settingValue(
settings,
"chat_integration_matrix_homeserver",
"chat_integration_matrix_homeserver must exist so the Matrix provider has a homeserver field to address the partner with",
);
settingValue(
settings,
"chat_integration_mattermost_webhook_url",
"chat_integration_mattermost_webhook_url must exist so the Mattermost provider has a webhook field to address the partner with",
);
const providers = await page.evaluate(async (base) => {
const res = await fetch(`${base}/admin/plugins/chat-integration/providers.json`, {
headers: { Accept: "application/json" },
credentials: "include",
});
let json;
try {
json = await res.json();
} catch {
json = null;
}
return { status: res.status, json };
}, discourseBaseUrl);
expect(
providers.status,
"the plugin's provider catalogue route must be served; a 404 proves the plugin did not land in the image",
).toBeLessThan(400);
const catalogue = JSON.stringify(providers.json || {});
expect(
catalogue,
"the provider catalogue must name the Matrix partner this deployment bridges",
).toMatch(/matrix/i);
expect(
catalogue,
"the provider catalogue must name the Mattermost partner this deployment bridges",
).toMatch(/mattermost/i);
} finally {
await page.context().clearCookies().catch(() => {});
}
});
@@ -0,0 +1,124 @@
const { test, expect } = require("@playwright/test");
const { resolveTimeout } = require("../timeouts");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { skipUnlessServiceEnabled } = require("../service-gating");
const { normalizeBaseUrl, decodeDotenvQuotedValue, performKeycloakLoginForm, gotoOnion } = require("../personas");
test.use({ ignoreHTTPSErrors: true });
const oidcIssuerUrl = normalizeBaseUrl(process.env.OIDC_ISSUER_URL || "");
const discourseBaseUrl = normalizeBaseUrl(process.env.DISCOURSE_BASE_URL || "");
const adminUsername = decodeDotenvQuotedValue(process.env.ADMIN_USERNAME);
const adminPassword = decodeDotenvQuotedValue(process.env.ADMIN_PASSWORD);
async function signInViaOidc(page) {
const expectedOidcAuthUrl = `${oidcIssuerUrl}/protocol/openid-connect/auth`;
await gotoOnion(page, `${discourseBaseUrl}/`);
const oidcSignIn = page
.locator("a, button")
.filter({ hasText: /sign\s*in\s+with\s+oidc|sign\s*in\s+with\s+sso|continue\s+with\s+oidc|continue\s+with\s+sso|single\s+sign[-\s]*on|log\s*in|sign\s*up/i })
.first();
if ((await oidcSignIn.count().catch(() => 0)) > 0) {
await oidcSignIn.click({ timeout: resolveTimeout(30_000) });
} else {
await gotoOnion(page, `${discourseBaseUrl}/auth/oidc`).catch(() => {});
}
await expect
.poll(() => page.url(), {
timeout: resolveTimeout(60_000),
message: `expected redirect to Keycloak OIDC auth (${expectedOidcAuthUrl})`,
})
.toContain(expectedOidcAuthUrl);
await performKeycloakLoginForm(page, adminUsername, adminPassword);
await expect
.poll(() => page.url(), {
timeout: resolveTimeout(60_000),
message: `expected redirect back to discourse at ${discourseBaseUrl}`,
})
.toContain(discourseBaseUrl);
}
async function readSiteSettings(page) {
const result = await page.evaluate(async (base) => {
const res = await fetch(`${base}/admin/site_settings.json`, {
headers: { Accept: "application/json" },
credentials: "include",
});
if (!res.ok) return { ok: false, status: res.status };
const body = await res.json();
return { ok: true, settings: (body && body.site_settings) || [] };
}, discourseBaseUrl);
expect(
result.ok,
`expected /admin/site_settings.json to be reachable as admin (status ${result.status})`,
).toBe(true);
return result.settings;
}
function settingValue(settings, name, why) {
const found = settings.find((s) => s && s.setting === name);
expect(found, why).toBeTruthy();
return String(found.value);
}
test("discourse-jitsi: the Jitsi plugin is installed and embeds the distinct meeting partner domain", async ({ page }) => {
skipUnlessAddonEnabled("discourse-jitsi");
skipUnlessServiceEnabled("sso");
test.setTimeout(resolveTimeout(120_000));
expect(oidcIssuerUrl, "OIDC_ISSUER_URL must be set").toBeTruthy();
expect(discourseBaseUrl, "DISCOURSE_BASE_URL must be set").toBeTruthy();
expect(adminUsername, "ADMIN_USERNAME must be set").toBeTruthy();
expect(adminPassword, "ADMIN_PASSWORD must be set").toBeTruthy();
const discourseHost = new URL(discourseBaseUrl).host;
try {
await page.context().clearCookies();
await signInViaOidc(page);
await expect(page.locator("body")).toContainText(
/topic|category|welcome|latest|discourse/i,
{ timeout: resolveTimeout(60_000) },
);
const settings = await readSiteSettings(page);
expect(
settingValue(
settings,
"jitsi_enabled",
"jitsi_enabled site setting must exist (discourse-jitsi plugin compiled into the image)",
).toLowerCase(),
"jitsi_enabled must be active so the composer offers a Jitsi meeting button",
).toBe("true");
const jitsiDomain = settingValue(
settings,
"jitsi_domain",
"jitsi_domain site setting must exist (discourse-jitsi plugin installed)",
)
.trim()
.replace(/^https?:\/\//i, "")
.replace(/\/.*$/, "");
expect(
jitsiDomain.length,
"jitsi_domain must name the Jitsi partner host so the embedded iframe has a conference server to load",
).toBeGreaterThan(0);
expect(
jitsiDomain.toLowerCase(),
"jitsi_domain must be the Jitsi partner host, NOT Discourse itself — otherwise the iframe loads the forum into itself instead of a meeting",
).not.toBe(discourseHost.toLowerCase());
} finally {
await page.context().clearCookies().catch(() => {});
}
});
@@ -0,0 +1,135 @@
const { test, expect } = require("@playwright/test");
const { resolveTimeout } = require("../timeouts");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { skipUnlessServiceEnabled } = require("../service-gating");
const { normalizeBaseUrl, decodeDotenvQuotedValue, performKeycloakLoginForm, gotoOnion } = require("../personas");
test.use({ ignoreHTTPSErrors: true });
const oidcIssuerUrl = normalizeBaseUrl(process.env.OIDC_ISSUER_URL || "");
const discourseBaseUrl = normalizeBaseUrl(process.env.DISCOURSE_BASE_URL || "");
const adminUsername = decodeDotenvQuotedValue(process.env.ADMIN_USERNAME);
const adminPassword = decodeDotenvQuotedValue(process.env.ADMIN_PASSWORD);
async function signInViaOidc(page) {
const expectedOidcAuthUrl = `${oidcIssuerUrl}/protocol/openid-connect/auth`;
await gotoOnion(page, `${discourseBaseUrl}/`);
const oidcSignIn = page
.locator("a, button")
.filter({ hasText: /sign\s*in\s+with\s+oidc|sign\s*in\s+with\s+sso|continue\s+with\s+oidc|continue\s+with\s+sso|single\s+sign[-\s]*on|log\s*in|sign\s*up/i })
.first();
if ((await oidcSignIn.count().catch(() => 0)) > 0) {
await oidcSignIn.click({ timeout: resolveTimeout(30_000) });
} else {
await gotoOnion(page, `${discourseBaseUrl}/auth/oidc`).catch(() => {});
}
await expect
.poll(() => page.url(), {
timeout: resolveTimeout(60_000),
message: `expected redirect to Keycloak OIDC auth (${expectedOidcAuthUrl})`,
})
.toContain(expectedOidcAuthUrl);
await performKeycloakLoginForm(page, adminUsername, adminPassword);
await expect
.poll(() => page.url(), {
timeout: resolveTimeout(60_000),
message: `expected redirect back to discourse at ${discourseBaseUrl}`,
})
.toContain(discourseBaseUrl);
}
async function readSiteSettings(page) {
const result = await page.evaluate(async (base) => {
const res = await fetch(`${base}/admin/site_settings.json`, {
headers: { Accept: "application/json" },
credentials: "include",
});
if (!res.ok) return { ok: false, status: res.status };
const body = await res.json();
return { ok: true, settings: (body && body.site_settings) || [] };
}, discourseBaseUrl);
expect(
result.ok,
`expected /admin/site_settings.json to be reachable as admin (status ${result.status})`,
).toBe(true);
return result.settings;
}
function settingValue(settings, name, why) {
const found = settings.find((s) => s && s.setting === name);
expect(found, why).toBeTruthy();
return String(found.value);
}
test("discourse-openid-connect: the OIDC plugin drives login against the distinct Keycloak partner", async ({ page }) => {
skipUnlessAddonEnabled("discourse-openid-connect");
skipUnlessServiceEnabled("sso");
test.setTimeout(resolveTimeout(120_000));
expect(oidcIssuerUrl, "OIDC_ISSUER_URL must be set").toBeTruthy();
expect(discourseBaseUrl, "DISCOURSE_BASE_URL must be set").toBeTruthy();
expect(adminUsername, "ADMIN_USERNAME must be set").toBeTruthy();
expect(adminPassword, "ADMIN_PASSWORD must be set").toBeTruthy();
const discourseHost = new URL(discourseBaseUrl).host;
const issuerHost = new URL(oidcIssuerUrl).host;
try {
await page.context().clearCookies();
await signInViaOidc(page);
await expect(page.locator("body")).toContainText(
/topic|category|welcome|latest|discourse/i,
{ timeout: resolveTimeout(60_000) },
);
const settings = await readSiteSettings(page);
expect(
settingValue(
settings,
"openid_connect_enabled",
"openid_connect_enabled site setting must exist (the bundled OIDC plugin is part of the Discourse image)",
).toLowerCase(),
"openid_connect_enabled must be active whenever the sso service is part of the deployment",
).toBe("true");
const discovery = settingValue(
settings,
"openid_connect_discovery_document",
"openid_connect_discovery_document site setting must exist so the plugin can resolve the partner's endpoints",
).trim();
expect(
discovery,
"the discovery document must be an absolute URL of the identity partner",
).toMatch(/^https?:\/\//i);
expect(
new URL(discovery).host.toLowerCase(),
"the discovery document must point at the Keycloak partner host, NOT at Discourse itself",
).not.toBe(discourseHost.toLowerCase());
expect(
new URL(discovery).host.toLowerCase(),
"the discovery document must point at the very issuer the login redirect went through, proving one identity partner rather than two unrelated configurations",
).toBe(issuerHost.toLowerCase());
expect(
settingValue(
settings,
"openid_connect_client_id",
"openid_connect_client_id site setting must exist",
).trim().length,
"openid_connect_client_id must be provisioned; an empty client id makes every authorization request fail at the partner",
).toBeGreaterThan(0);
} finally {
await page.context().clearCookies().catch(() => {});
}
});
@@ -0,0 +1,119 @@
const { test, expect } = require("@playwright/test");
const { resolveTimeout } = require("../timeouts");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { skipUnlessServiceEnabled } = require("../service-gating");
const { normalizeBaseUrl, decodeDotenvQuotedValue, performKeycloakLoginForm, gotoOnion } = require("../personas");
test.use({ ignoreHTTPSErrors: true });
const oidcIssuerUrl = normalizeBaseUrl(process.env.OIDC_ISSUER_URL || "");
const discourseBaseUrl = normalizeBaseUrl(process.env.DISCOURSE_BASE_URL || "");
const adminUsername = decodeDotenvQuotedValue(process.env.ADMIN_USERNAME);
const adminPassword = decodeDotenvQuotedValue(process.env.ADMIN_PASSWORD);
async function signInViaOidc(page) {
const expectedOidcAuthUrl = `${oidcIssuerUrl}/protocol/openid-connect/auth`;
await gotoOnion(page, `${discourseBaseUrl}/`);
const oidcSignIn = page
.locator("a, button")
.filter({ hasText: /sign\s*in\s+with\s+oidc|sign\s*in\s+with\s+sso|continue\s+with\s+oidc|continue\s+with\s+sso|single\s+sign[-\s]*on|log\s*in|sign\s*up/i })
.first();
if ((await oidcSignIn.count().catch(() => 0)) > 0) {
await oidcSignIn.click({ timeout: resolveTimeout(30_000) });
} else {
await gotoOnion(page, `${discourseBaseUrl}/auth/oidc`).catch(() => {});
}
await expect
.poll(() => page.url(), {
timeout: resolveTimeout(60_000),
message: `expected redirect to Keycloak OIDC auth (${expectedOidcAuthUrl})`,
})
.toContain(expectedOidcAuthUrl);
await performKeycloakLoginForm(page, adminUsername, adminPassword);
await expect
.poll(() => page.url(), {
timeout: resolveTimeout(60_000),
message: `expected redirect back to discourse at ${discourseBaseUrl}`,
})
.toContain(discourseBaseUrl);
}
async function readSiteSettings(page) {
const result = await page.evaluate(async (base) => {
const res = await fetch(`${base}/admin/site_settings.json`, {
headers: { Accept: "application/json" },
credentials: "include",
});
if (!res.ok) return { ok: false, status: res.status };
const body = await res.json();
return { ok: true, settings: (body && body.site_settings) || [] };
}, discourseBaseUrl);
expect(
result.ok,
`expected /admin/site_settings.json to be reachable as admin (status ${result.status})`,
).toBe(true);
return result.settings;
}
function settingValue(settings, name, why) {
const found = settings.find((s) => s && s.setting === name);
expect(found, why).toBeTruthy();
return String(found.value);
}
test("discourse-prometheus: the exporter plugin is installed and its scrape collector is addressable", async ({ page }) => {
skipUnlessAddonEnabled("discourse-prometheus");
skipUnlessServiceEnabled("sso");
test.setTimeout(resolveTimeout(120_000));
expect(oidcIssuerUrl, "OIDC_ISSUER_URL must be set").toBeTruthy();
expect(discourseBaseUrl, "DISCOURSE_BASE_URL must be set").toBeTruthy();
expect(adminUsername, "ADMIN_USERNAME must be set").toBeTruthy();
expect(adminPassword, "ADMIN_PASSWORD must be set").toBeTruthy();
try {
await page.context().clearCookies();
await signInViaOidc(page);
await expect(page.locator("body")).toContainText(
/topic|category|welcome|latest|discourse/i,
{ timeout: resolveTimeout(60_000) },
);
const settings = await readSiteSettings(page);
const collectorPort = settingValue(
settings,
"prometheus_collector_port",
"prometheus_collector_port site setting must exist (discourse-prometheus plugin compiled into the image)",
).trim();
expect(
collectorPort,
"prometheus_collector_port must be a numeric TCP port; without it the Prometheus partner has nothing to scrape",
).toMatch(/^[0-9]+$/);
expect(
Number(collectorPort),
"prometheus_collector_port must be a valid TCP port",
).toBeGreaterThan(0);
expect(
settingValue(
settings,
"prometheus_trusted_ip_allowlist_regex",
"prometheus_trusted_ip_allowlist_regex site setting must exist so the metrics surface can be opened to the Prometheus partner only",
),
"the allowlist setting must be a string the plugin can match scrape sources against",
).not.toBeNull();
} finally {
await page.context().clearCookies().catch(() => {});
}
});
@@ -1,6 +1,7 @@
const { test, expect } = require("@playwright/test");
const { resolveTimeout } = require("../timeouts");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { skipUnlessServiceEnabled } = require("../service-gating");
const { normalizeBaseUrl, decodeDotenvQuotedValue, performKeycloakLoginForm, gotoOnion } = require("../personas");
test.use({ ignoreHTTPSErrors: true });
@@ -49,6 +50,7 @@ function pluginName(plugin) {
test("docker_manager: the docker_manager plugin is installed and registered on the Discourse instance", async ({ page }) => {
skipUnlessAddonEnabled("docker_manager");
skipUnlessServiceEnabled("sso");
expect(oidcIssuerUrl, "OIDC_ISSUER_URL must be set").toBeTruthy();
expect(discourseBaseUrl, "DISCOURSE_BASE_URL must be set").toBeTruthy();
@@ -4,6 +4,8 @@ enabled: true
required: false
mechanism: plugin
source: upstream
bridges:
- mastodon
update:
monitored: true
catalog: discourse-meta
@@ -2,7 +2,7 @@
enabled: "{{ lookup('config', 'web-app-discourse', 'services.litellm.enabled') | bool }}"
required: true
mechanism: plugin
source: bundled # Exception: cloning it over the copy already in $home/plugins aborts the after_code hook
source: bundled
bridges:
- litellm
config:
@@ -0,0 +1,14 @@
---
enabled: "{{ lookup('config', 'web-app-discourse', 'services.bigbluebutton.enabled') | bool }}"
required: false
mechanism: plugin
source: upstream
bridges:
- bigbluebutton
update:
monitored: true
catalog: discourse-meta
upstream_id: discourse-bbb
config:
repository: https://github.com/discourse/discourse-bbb.git
ref: main
@@ -0,0 +1,16 @@
---
enabled: "{{ (lookup('config', 'web-app-discourse', 'services.matrix.enabled') | bool) or (lookup('config', 'web-app-discourse', 'services.mattermost.enabled') |
bool) }}"
required: false
mechanism: plugin
source: upstream
bridges:
- matrix
- mattermost
update:
monitored: true
catalog: discourse-meta
upstream_id: discourse-chat-integration
config:
repository: https://github.com/discourse/discourse-chat-integration.git
ref: main
@@ -0,0 +1,14 @@
---
enabled: "{{ lookup('config', 'web-app-discourse', 'services.jitsi.enabled') | bool }}"
required: false
mechanism: plugin
source: upstream
bridges:
- jitsi
update:
monitored: true
catalog: discourse-meta
upstream_id: discourse-jitsi
config:
repository: https://github.com/discourse/discourse-jitsi.git
ref: main
@@ -0,0 +1,7 @@
---
enabled: "{{ lookup('config', 'web-app-discourse', 'services.sso.enabled') | bool }}"
required: false
mechanism: plugin
source: bundled
bridges:
- sso
@@ -0,0 +1,14 @@
---
enabled: "{{ lookup('config', 'web-app-discourse', 'services.prometheus.enabled') | bool }}"
required: false
mechanism: plugin
source: upstream
bridges:
- prometheus
update:
monitored: true
catalog: discourse-meta
upstream_id: discourse-prometheus
config:
repository: https://github.com/discourse/discourse-prometheus.git
ref: main
@@ -0,0 +1 @@
---
@@ -0,0 +1 @@
---
@@ -0,0 +1 @@
---
@@ -0,0 +1 @@
---
@@ -0,0 +1 @@
---
@@ -0,0 +1,49 @@
const { test, expect } = require("@playwright/test");
const { resolveTimeout } = require("../timeouts");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { skipUnlessServiceEnabled } = require("../service-gating");
const { decodeDotenvQuotedValue } = require("../personas");
const shared = require("../_shared");
test.use({ ignoreHTTPSErrors: true });
const smtpHost = decodeDotenvQuotedValue(process.env.EMAIL_SMTP_HOST || "");
const ACCOUNTS_ROUTE =
'/api/resource/Email Account?limit_page_length=0&fields=["name","smtp_server","enable_outgoing"]';
test("addon email_account: ERPNext's outgoing account sends through the deployed Mailu SMTP host", async ({ page }) => {
skipUnlessAddonEnabled("email_account");
skipUnlessServiceEnabled("email");
test.setTimeout(resolveTimeout(240_000));
const { erpnextBaseUrl, adminNativePassword } = shared.env;
expect(erpnextBaseUrl, "ERPNEXT_BASE_URL must be set").toBeTruthy();
expect(smtpHost, "EMAIL_SMTP_HOST must be set when the email service is on").toBeTruthy();
expect(adminNativePassword, "ADMIN_NATIVE_PASSWORD must be set").toBeTruthy();
await page.context().clearCookies();
await shared.signInViaErpnextLocal(page, "Administrator", adminNativePassword, "addon-email-account");
const response = await page.request.get(`${erpnextBaseUrl}${ACCOUNTS_ROUTE}`, {
timeout: resolveTimeout(60_000),
});
expect(
response.status(),
"the Email Account resource must answer for the signed-in administrator",
).toBeLessThan(400);
const accounts = (await response.json()).data || [];
const outgoing = accounts.filter((account) => Number(account.enable_outgoing) === 1);
expect(
outgoing.length,
"Frappe must hold at least one outgoing Email Account; without it nothing in ERPNext can " +
"send and the declared Mailu bridge is inert",
).toBeGreaterThan(0);
expect(
outgoing.map((account) => account.smtp_server),
`an outgoing Email Account must name the deployed Mailu host (${smtpHost}); another value ` +
"means the account was provisioned against a different transport",
).toContain(smtpHost);
});
@@ -0,0 +1,49 @@
const { test, expect } = require("@playwright/test");
const { resolveTimeout } = require("../timeouts");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { skipUnlessServiceEnabled } = require("../service-gating");
const { gotoOnion } = require("../personas");
const shared = require("../_shared");
test.use({ ignoreHTTPSErrors: true });
test("addon social_login_key: Frappe's login offers the deployed Keycloak and hands over to it", async ({ page }) => {
skipUnlessAddonEnabled("social_login_key");
skipUnlessServiceEnabled("sso");
test.setTimeout(resolveTimeout(180_000));
const { erpnextBaseUrl, oidcIssuerUrl } = shared.env;
expect(erpnextBaseUrl, "ERPNEXT_BASE_URL must be set").toBeTruthy();
expect(oidcIssuerUrl, "OIDC_ISSUER_URL must be set when SSO is on").toBeTruthy();
await page.context().clearCookies();
await gotoOnion(page, `${erpnextBaseUrl}/login`);
const oidcSignIn = page
.locator("a, button")
.filter({ hasText: /sso\s*login|sign\s*in\s+with|continue\s+with|single\s+sign[-\s]*on|keycloak|infinito/i })
.first();
await expect(
oidcSignIn,
"the Social Login Key must render its provider button on /login; an absent button means the " +
"doctype was never written or the Frappe workers did not pick it up",
).toBeVisible({ timeout: resolveTimeout(60_000) });
await oidcSignIn.click({ timeout: resolveTimeout(30_000) });
const expectedAuthUrl = `${oidcIssuerUrl}/protocol/openid-connect/auth`;
await expect
.poll(() => page.url(), {
timeout: resolveTimeout(60_000),
message:
`the provider button must hand over to the configured issuer (${expectedAuthUrl}); a ` +
"different host means the Social Login Key points at another Keycloak than the deployed one",
})
.toContain(expectedAuthUrl);
expect(
new URL(page.url()).searchParams.get("client_id"),
"the authorization request must carry the OIDC client id this deployment registered",
).toBeTruthy();
});
@@ -0,0 +1,7 @@
---
enabled: "{{ lookup('config', 'web-app-erpnext', 'services.email.enabled') | bool }}"
required: false
mechanism: module
source: bundled
bridges:
- email
@@ -0,0 +1,7 @@
---
enabled: "{{ lookup('config', 'web-app-erpnext', 'services.sso.enabled') | bool }}"
required: false
mechanism: module
source: bundled
bridges:
- sso
@@ -0,0 +1,24 @@
---
- name: "📌 apply outbound Email Account (Mailu SMTP; inbound IMAP deferred)"
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
ansible.builtin.shell:
cmd: > # nocheck: container-exec-resolver runtime-resolved CID, delegated to the hosting node
container exec -i
-e SITE_NAME -e MAIL_FROM_NAME -e MAIL_FROM_ADDRESS
-e SMTP_HOST -e SMTP_PORT -e SMTP_USER -e SMTP_PASS
{{ ERPNEXT_BACKEND_LOCAL_CID | quote }}
{{ ERPNEXT_BENCH_PYTHON }}
stdin: "{{ lookup('ansible.builtin.file', role_path ~ '/files/python/apply/email_account.py') }}"
delegate_to: "{{ ERPNEXT_BACKEND_HOST_NODE }}"
environment:
SITE_NAME: "{{ ERPNEXT_SITE_NAME }}"
MAIL_FROM_NAME: "{{ ORGANIZATION }} ERPNext"
MAIL_FROM_ADDRESS: "{{ ERPNEXT_MAIL.username | default('') }}"
SMTP_HOST: "{{ ERPNEXT_MAIL.host | default('') }}"
SMTP_PORT: "{{ ERPNEXT_MAIL.port | default(587) }}"
SMTP_USER: "{{ ERPNEXT_MAIL.username | default('') }}"
SMTP_PASS: "{{ ERPNEXT_MAIL.password | default('') }}"
when: ERPNEXT_MAIL.host is defined and (ERPNEXT_MAIL.host | length > 0)
register: erpnext_email_apply
changed_when: false
timeout: "{{ lookup('timeout', 60) | int }}"
@@ -0,0 +1,67 @@
---
- name: "📌 apply Social Login Key for Keycloak"
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
ansible.builtin.shell:
cmd: > # nocheck: container-exec-resolver runtime-resolved CID, delegated to the hosting node
container exec -i
-e SITE_NAME -e PROVIDER_NAME -e BUTTON_TEXT
-e CLIENT_ID -e CLIENT_SECRET -e ISSUER_URL
-e GROUP_ROLE_MAP_JSON
{{ ERPNEXT_BACKEND_LOCAL_CID | quote }}
{{ ERPNEXT_BENCH_PYTHON }}
stdin: "{{ lookup('ansible.builtin.file', role_path ~ '/files/python/apply/oidc_settings.py') }}"
delegate_to: "{{ ERPNEXT_BACKEND_HOST_NODE }}"
environment:
SITE_NAME: "{{ ERPNEXT_SITE_NAME }}"
PROVIDER_NAME: "keycloak"
BUTTON_TEXT: "{{ OIDC.BUTTON_TEXT }}"
CLIENT_ID: "{{ OIDC.CLIENT.ID }}"
CLIENT_SECRET: "{{ OIDC.CLIENT.SECRET }}"
ISSUER_URL: "{{ OIDC.CLIENT.ISSUER_URL }}"
GROUP_ROLE_MAP_JSON: "{{ ERPNEXT_OIDC_GROUP_ROLE_MAP | to_json }}"
register: erpnext_oidc_apply
changed_when: "'OIDC_SLK_CHANGED' in erpnext_oidc_apply.stdout"
timeout: "{{ lookup('timeout', 60) | int }}"
- name: "🔄 Restart Frappe on Social Login Key change (compose)"
ansible.builtin.shell: |
set -euo pipefail
container restart {{ ERPNEXT_BACKEND_CONTAINER }}
changed_when: true
when:
- "'OIDC_SLK_CHANGED' in erpnext_oidc_apply.stdout"
- IS_COMPOSE_MODE
timeout: "{{ lookup('timeout', 120) | int }}"
- name: "🔄 Restart Frappe on Social Login Key change (swarm)"
ansible.builtin.command: >-
container service update --force {{ entity_name }}_backend
changed_when: true
when:
- "'OIDC_SLK_CHANGED' in erpnext_oidc_apply.stdout"
- IS_SWARM_MODE
timeout: "{{ lookup('timeout', 600) | int }}"
- name: "🔍 Re-resolve ERPNext backend node + CID after OIDC restart"
ansible.builtin.include_tasks: "{{ lookup('path_absolute', 'roles/sys-svc-compose/tasks/utils/swarm/resolve_host_cid.yml') }}"
vars:
resolve_service_key: backend
resolve_output_prefix: ERPNEXT_BACKEND
resolve_compose_cid: "{{ ERPNEXT_BACKEND_CONTAINER }}"
resolve_converge_retries: 60
resolve_converge_delay: 5
when: "'OIDC_SLK_CHANGED' in erpnext_oidc_apply.stdout"
- name: "⏳ wait for {{ ERPNEXT_BACKEND_CONTAINER }} gunicorn TCP port after OIDC restart"
ansible.builtin.shell: |
set -eo pipefail
# nocheck: container-exec-resolver runtime-resolved CID, delegated to the hosting node
container exec {{ ERPNEXT_BACKEND_LOCAL_CID | quote }} \
bash -c 'exec 3<>/dev/tcp/localhost/{{ ERPNEXT_BACKEND_PORT }} && exec 3<&- 3>&-'
delegate_to: "{{ ERPNEXT_BACKEND_HOST_NODE }}"
register: erpnext_backend_ready_post_oidc
changed_when: false
retries: 60
delay: 5
until: erpnext_backend_ready_post_oidc.rc == 0
when: "'OIDC_SLK_CHANGED' in erpnext_oidc_apply.stdout"
@@ -15,6 +15,8 @@ CANONICAL_DOMAIN={{ lookup('domain', application_id) | dotenv_quote }}
# nocheck: playwright-service-gate — covered by sys-svc-mail integration tests, not the persona surface
EMAIL_SERVICE_ENABLED={{ lookup('config', application_id, 'services.email.enabled') | string | lower }}
EMAIL_SMTP_HOST={{ ERPNEXT_MAIL.host | default('') | dotenv_quote }}
ERPNEXT_BASE_URL={{ lookup('tls', application_id, 'url.base') | dotenv_quote }}
# nocheck: playwright-service-gate — persona drives OIDC path; LDAP federation exercised in LDAP-only matrix variant
@@ -31,3 +33,5 @@ MATOMO_SERVICE_ENABLED={{ lookup('config', application_id, 'services.matomo.enab
OIDC_ISSUER_URL={{ OIDC.CLIENT.ISSUER_URL | dotenv_quote }}
SSO_SERVICE_ENABLED={{ ERPNEXT_OIDC_ENABLED | string | lower }}
{{ lookup('addon_env_flags', application_id) }}
@@ -0,0 +1,55 @@
const { test, expect } = require("@playwright/test");
const { resolveTimeout } = require("../timeouts");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { skipUnlessServiceEnabled } = require("../service-gating");
const { clickOidcLoginLink, gotoOnion, normalizeBaseUrl } = require("../personas");
test.use({ ignoreHTTPSErrors: true });
const appBaseUrl = normalizeBaseUrl(process.env.APP_BASE_URL || "");
test("addon oidc: EspoCRM's sign-in hands over to the deployed Keycloak as its OIDC provider", async ({ page }) => {
skipUnlessAddonEnabled("oidc");
skipUnlessServiceEnabled("sso");
test.setTimeout(resolveTimeout(180_000));
expect(appBaseUrl, "APP_BASE_URL must be set").toBeTruthy();
const expectedBase = appBaseUrl.replace(/\/$/, "");
await page.context().clearCookies();
await gotoOnion(page, `${expectedBase}/`, { waitUntil: "domcontentloaded" });
let authPage = page;
if (!page.url().includes("openid-connect/auth")) {
const strictLogin = page
.getByRole("link", { name: /^\s*(log\s*in|sign\s*in|login|sso)\s*$/i })
.or(page.getByRole("button", { name: /^\s*(log\s*in|sign\s*in|login|sso)\s*$/i }))
.first();
const looseLogin = page
.getByRole("link", { name: /log\s*in|sign\s*in|sso/i })
.or(page.getByRole("button", { name: /log\s*in|sign\s*in|sso/i }))
.first();
authPage = (await clickOidcLoginLink(page, strictLogin, looseLogin)) || page;
}
await expect
.poll(() => authPage.url(), {
timeout: resolveTimeout(60_000),
message:
"with authenticationMethod Oidc the sign-in must reach Keycloak's authorization " +
"endpoint; a login form that stays on EspoCRM means ESPOCRM_CONFIG_OIDC_* never " +
"reached the app's config and it still authenticates locally",
})
.toContain("openid-connect/auth");
const authUrl = new URL(authPage.url());
expect(
authUrl.searchParams.get("client_id"),
"the authorization request must carry the OIDC client id this deployment registered",
).toBeTruthy();
expect(
authUrl.searchParams.get("redirect_uri") || "",
"the authorization request must come back to this EspoCRM instance, proving the callback " +
"belongs to the deployed app and not to a default provider entry",
).toContain(new URL(expectedBase).host);
});
@@ -0,0 +1,98 @@
const { test, expect } = require("@playwright/test");
const { resolveTimeout } = require("../timeouts");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { skipUnlessServiceEnabled } = require("../service-gating");
const {
clickOidcLoginLink,
decodeDotenvQuotedValue,
gotoOnion,
normalizeBaseUrl,
performKeycloakLogin,
safeIsEnabled,
} = require("../personas");
test.use({ ignoreHTTPSErrors: true });
const appBaseUrl = normalizeBaseUrl(process.env.APP_BASE_URL || "");
const canonicalDomain = decodeDotenvQuotedValue(process.env.CANONICAL_DOMAIN || "");
const smtpHost = decodeDotenvQuotedValue(process.env.EMAIL_SMTP_HOST || "");
const adminUsername = decodeDotenvQuotedValue(process.env.ADMIN_USERNAME || "");
const adminPassword = decodeDotenvQuotedValue(process.env.ADMIN_PASSWORD || "");
const adminNativePassword = decodeDotenvQuotedValue(process.env.ADMIN_NATIVE_PASSWORD || "");
test("addon outbound-emails: EspoCRM sends through the deployed Mailu SMTP host", async ({ page }) => {
skipUnlessAddonEnabled("outbound-emails");
skipUnlessServiceEnabled("email");
test.setTimeout(resolveTimeout(240_000));
expect(appBaseUrl, "APP_BASE_URL must be set").toBeTruthy();
expect(smtpHost, "EMAIL_SMTP_HOST must be set when the email service is on").toBeTruthy();
expect(adminUsername, "ADMIN_USERNAME must be set").toBeTruthy();
const expectedBase = appBaseUrl.replace(/\/$/, "");
const oidcEnabled = safeIsEnabled("sso");
await page.context().clearCookies();
await gotoOnion(page, `${expectedBase}/`, { waitUntil: "domcontentloaded" });
if (oidcEnabled) {
expect(canonicalDomain, "CANONICAL_DOMAIN must be set when sso is on").toBeTruthy();
expect(adminPassword, "ADMIN_PASSWORD must be set when sso is on").toBeTruthy();
let authPage = page;
if (!page.url().includes("openid-connect/auth")) {
const strictLogin = page
.getByRole("link", { name: /^\s*(log\s*in|sign\s*in|login|sso)\s*$/i })
.or(page.getByRole("button", { name: /^\s*(log\s*in|sign\s*in|login|sso)\s*$/i }))
.first();
const looseLogin = page
.getByRole("link", { name: /log\s*in|sign\s*in|sso/i })
.or(page.getByRole("button", { name: /log\s*in|sign\s*in|sso/i }))
.first();
authPage = (await clickOidcLoginLink(page, strictLogin, looseLogin)) || page;
}
if (authPage.url().includes("openid-connect/auth")) {
await performKeycloakLogin(authPage, adminUsername, adminPassword, canonicalDomain);
}
} else {
test.skip(
!adminNativePassword,
"no native admin password: the role withholds it when LDAP owns authentication",
);
test.skip(
safeIsEnabled("recaptcha"),
"reCAPTCHA v3 guards the native login form with live keys a headless persona cannot satisfy",
);
const passwordField = page.locator("input[type='password']:visible").first();
await passwordField.waitFor({ state: "visible", timeout: resolveTimeout(90_000) });
await page.locator("input[name='username']:visible").first().fill(adminUsername);
await passwordField.fill(adminNativePassword);
await passwordField.press("Enter");
}
await expect
.poll(() => page.url(), {
timeout: resolveTimeout(120_000),
message: "expected the EspoCRM login to land back on the application",
})
.toContain(expectedBase);
await gotoOnion(page, `${expectedBase}/#Admin/outboundEmails`, { waitUntil: "domcontentloaded" });
await page.reload({ waitUntil: "domcontentloaded" });
const smtpServerField = page.locator("[data-name='smtpServer'], input[name='smtpServer']").first();
await expect(
smtpServerField,
"the Outbound Emails admin panel must render the SMTP server field; without it the login " +
"did not reach an administrator surface and the assertion below would prove nothing",
).toBeAttached({ timeout: resolveTimeout(120_000) });
const rendered =
(await smtpServerField.inputValue().catch(() => null)) ??
(await smtpServerField.innerText().catch(() => ""));
expect(
rendered,
`EspoCRM's outbound mailer must carry the deployed Mailu host (${smtpHost}); another value ` +
"means ESPOCRM_CONFIG_SMTP_SERVER never reached the app's own config",
).toContain(smtpHost);
});
@@ -0,0 +1,7 @@
---
enabled: "{{ lookup('config', 'web-app-espocrm', 'services.sso.enabled') | bool }}"
required: false
mechanism: extension
source: bundled
bridges:
- sso
@@ -0,0 +1,7 @@
---
enabled: "{{ lookup('config', 'web-app-espocrm', 'services.email.enabled') | bool }}"
required: false
mechanism: extension
source: bundled
bridges:
- email
@@ -0,0 +1 @@
---
@@ -0,0 +1 @@
---
@@ -20,6 +20,8 @@ CSS_SERVICE_ENABLED={{ lookup('config', application_id, 'services.css.enabled')
# nocheck: playwright-service-gate — templates/env.j2 hands SMTP to the app via ESPOCRM_CONFIG_SMTP_*; no persona surface sends mail, real delivery is asserted in roles/web-app-mailu/files/playwright/playwright.spec.js
EMAIL_SERVICE_ENABLED={{ lookup('config', application_id, 'services.email.enabled') | string | lower }}
EMAIL_SMTP_HOST={{ lookup('email', application_id).host | dotenv_quote }}
# nocheck: playwright-service-gate — the persona drives the OIDC login path; ESPOCRM_CONFIG_AUTHENTICATION_METHOD=LDAP is exercised in the LDAP-only matrix variant
LDAP_SERVICE_ENABLED={{ lookup('config', application_id, 'services.ldap.enabled') | string | lower }}
@@ -51,3 +53,5 @@ SEAWEEDFS_S3_URL={{ lookup('canonical_url', 'web-svc-seaweedfs', 'api', consumer
SEAWEEDFS_SERVICE_ENABLED={{ lookup('config', application_id, 'services.seaweedfs.enabled') | string | lower }}
SSO_SERVICE_ENABLED={{ lookup('config', application_id, 'services.sso.enabled') | string | lower }}
{{ lookup('addon_env_flags', application_id) }}
@@ -0,0 +1,13 @@
const { test } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { skipUnlessServiceEnabled } = require("../service-gating");
const { expectInstanceIntegrationIsAdminOnly } = require("../test-integrations");
test.use({ ignoreHTTPSErrors: true });
test("addon confluence: GitLab serves the Confluence integration surface for the deployed partner", async ({ page }) => {
skipUnlessAddonEnabled("confluence");
skipUnlessServiceEnabled("confluence");
await expectInstanceIntegrationIsAdminOnly(page, "confluence", "Confluence");
});
@@ -0,0 +1,13 @@
const { test } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { skipUnlessServiceEnabled } = require("../service-gating");
const { expectInstanceIntegrationIsAdminOnly } = require("../test-integrations");
test.use({ ignoreHTTPSErrors: true });
test("addon jenkins: GitLab serves the Jenkins integration surface for the deployed partner", async ({ page }) => {
skipUnlessAddonEnabled("jenkins");
skipUnlessServiceEnabled("jenkins");
await expectInstanceIntegrationIsAdminOnly(page, "jenkins", "Jenkins");
});
@@ -0,0 +1,13 @@
const { test } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { skipUnlessServiceEnabled } = require("../service-gating");
const { expectInstanceIntegrationIsAdminOnly } = require("../test-integrations");
test.use({ ignoreHTTPSErrors: true });
test("addon jira: GitLab serves the Jira integration surface for the deployed partner", async ({ page }) => {
skipUnlessAddonEnabled("jira");
skipUnlessServiceEnabled("jira");
await expectInstanceIntegrationIsAdminOnly(page, "jira", "Jira");
});
@@ -0,0 +1,13 @@
const { test } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { skipUnlessServiceEnabled } = require("../service-gating");
const { expectInstanceIntegrationIsAdminOnly } = require("../test-integrations");
test.use({ ignoreHTTPSErrors: true });
test("addon matrix: GitLab serves the Matrix notifications integration surface for the deployed partner", async ({ page }) => {
skipUnlessAddonEnabled("matrix");
skipUnlessServiceEnabled("matrix");
await expectInstanceIntegrationIsAdminOnly(page, "matrix", "Matrix");
});
@@ -0,0 +1,13 @@
const { test } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { skipUnlessServiceEnabled } = require("../service-gating");
const { expectInstanceIntegrationIsAdminOnly } = require("../test-integrations");
test.use({ ignoreHTTPSErrors: true });
test("addon mattermost: GitLab serves the Mattermost notifications integration surface for the deployed partner", async ({ page }) => {
skipUnlessAddonEnabled("mattermost");
skipUnlessServiceEnabled("mattermost");
await expectInstanceIntegrationIsAdminOnly(page, "mattermost", "Mattermost notifications");
});
@@ -0,0 +1,28 @@
const { test, expect } = require("@playwright/test");
const { resolveTimeout } = require("../timeouts");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { skipUnlessServiceEnabled } = require("../service-gating");
const { gotoOnion, normalizeBaseUrl } = require("../personas");
test.use({ ignoreHTTPSErrors: true });
test("addon openid_connect: the sign-in page offers the OmniAuth provider the role renders into gitlab.yml", async ({ page }) => {
skipUnlessAddonEnabled("openid_connect");
skipUnlessServiceEnabled("sso");
const appBaseUrl = normalizeBaseUrl(process.env.APP_BASE_URL || "");
expect(appBaseUrl, "APP_BASE_URL must be set").toBeTruthy();
const response = await gotoOnion(page, `${appBaseUrl}/users/sign_in`, { waitUntil: "domcontentloaded" });
expect(response, "Expected a response from the GitLab sign-in page").toBeTruthy();
expect(response.status(), "Expected the GitLab sign-in page status < 400").toBeLessThan(400);
const providerEntry = page
.locator("form[action*='/users/auth/openid_connect']")
.or(page.locator("a[href*='/users/auth/openid_connect']"))
.first();
await expect(
providerEntry,
"the sign-in page must offer the openid_connect OmniAuth entry point; its absence means the omniauth provider block in templates/config/gitlab.yml.j2 never reached the running webservice",
).toBeAttached({ timeout: resolveTimeout(30_000) });
});
@@ -0,0 +1,9 @@
---
enabled: "{{ lookup('config', 'web-app-gitlab', 'services.confluence.enabled') | bool }}"
required: false
mechanism: plugin
source: bundled
bridges:
- confluence
config:
confluence_url: "{{ lookup('tls', 'web-app-confluence', 'url.base') }}"
@@ -0,0 +1,9 @@
---
enabled: "{{ lookup('config', 'web-app-gitlab', 'services.jenkins.enabled') | bool }}"
required: false
mechanism: plugin
source: bundled
bridges:
- jenkins
config:
jenkins_url: "{{ lookup('tls', 'web-app-jenkins', 'url.base') }}"
@@ -0,0 +1,9 @@
---
enabled: "{{ lookup('config', 'web-app-gitlab', 'services.jira.enabled') | bool }}"
required: false
mechanism: plugin
source: bundled
bridges:
- jira
config:
url: "{{ lookup('tls', 'web-app-jira', 'url.base') }}"
@@ -0,0 +1,9 @@
---
enabled: "{{ lookup('config', 'web-app-gitlab', 'services.matrix.enabled') | bool }}"
required: false
mechanism: plugin
source: bundled
bridges:
- matrix
config:
hostname: "{{ lookup('canonical_url', 'web-app-matrix', 'synapse') }}"
@@ -0,0 +1,9 @@
---
enabled: "{{ lookup('config', 'web-app-gitlab', 'services.mattermost.enabled') | bool }}"
required: false
mechanism: plugin
source: bundled
bridges:
- mattermost
config:
mattermost_url: "{{ lookup('tls', 'web-app-mattermost', 'url.base') }}"
@@ -0,0 +1,7 @@
---
enabled: "{{ lookup('config', 'web-app-gitlab', 'services.sso.enabled') | bool }}"
required: false
mechanism: plugin
source: bundled
bridges:
- sso
@@ -0,0 +1 @@
---
@@ -0,0 +1 @@
---
@@ -0,0 +1 @@
---
@@ -0,0 +1 @@
---
@@ -0,0 +1 @@
---
@@ -0,0 +1 @@
---
@@ -10,18 +10,28 @@ BIBER_USERNAME={{ lookup('users', 'biber').username | dotenv_quote }}
CANONICAL_DOMAIN={{ lookup('domain', application_id) | dotenv_quote }}
CONFLUENCE_SERVICE_ENABLED={{ lookup('config', application_id, 'services.confluence.enabled') | string | lower }}
# nocheck: playwright-service-gate — web-svc-css is injected at the vhost level; assertCspInjections resolves the flag through a loop variable, never a literal gate
CSS_SERVICE_ENABLED={{ lookup('config', application_id, 'services.css.enabled') | string | lower }}
# nocheck: playwright-service-gate — templates/config/smtp_settings.rb.j2 wires ActionMailer outbound-only; no persona surface sends mail, real delivery is asserted in roles/web-app-mailu/files/playwright/playwright.spec.js
EMAIL_SERVICE_ENABLED={{ lookup('config', application_id, 'services.email.enabled') | string | lower }}
JENKINS_SERVICE_ENABLED={{ lookup('config', application_id, 'services.jenkins.enabled') | string | lower }}
JIRA_SERVICE_ENABLED={{ lookup('config', application_id, 'services.jira.enabled') | string | lower }}
LOGOUT_SERVICE_ENABLED={{ lookup('config', application_id, 'services.logout.enabled') | string | lower }}
MATOMO_BASE_URL={{ lookup('tls', 'web-app-matomo', 'url.base') | dotenv_quote }}
MATOMO_SERVICE_ENABLED={{ lookup('config', application_id, 'services.matomo.enabled') | string | lower }}
MATRIX_SERVICE_ENABLED={{ lookup('config', application_id, 'services.matrix.enabled') | string | lower }}
MATTERMOST_SERVICE_ENABLED={{ lookup('config', application_id, 'services.mattermost.enabled') | string | lower }}
MCP_ENDPOINT_PATH={{ GITLAB_MCP_UPSTREAM_PATH | dotenv_quote }}
MCP_SERVICE_ENABLED={{ lookup('config', application_id, 'mcp.enabled') | string | lower }}
@@ -42,3 +52,5 @@ REDIS_SERVICE_ENABLED={{ lookup('config', application_id, 'services.redis.enable
SEAWEEDFS_SERVICE_ENABLED={{ lookup('config', application_id, 'services.seaweedfs.enabled') | string | lower }}
SSO_SERVICE_ENABLED={{ lookup('config', application_id, 'services.sso.enabled') | string | lower }}
{{ lookup('addon_env_flags', application_id) }}
@@ -0,0 +1,24 @@
const { test, expect } = require("@playwright/test");
const { resolveTimeout } = require("../timeouts");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { skipUnlessServiceEnabled } = require("../service-gating");
const { fetchPluginRecord, loginAsAdministrator } = require("../jenkins-login");
test.use({ ignoreHTTPSErrors: true });
test("addon gitea: the Gitea plugin is installed and active", async ({ page }) => {
skipUnlessAddonEnabled("gitea");
skipUnlessServiceEnabled("gitea");
test.setTimeout(resolveTimeout(180_000));
await loginAsAdministrator(page);
const plugin = await fetchPluginRecord(page, "gitea");
expect(
plugin,
"the plugin manager must list 'gitea'; it is absent when the addon declaration did not " +
"reach templates/plugins.txt.j2 and jenkins-plugin-cli never fetched it",
).toBeTruthy();
expect(plugin.active, "the Gitea plugin must be active, not merely downloaded").toBe(true);
expect(plugin.enabled, "the Gitea plugin must be enabled in the controller").toBe(true);
});
@@ -0,0 +1,24 @@
const { test, expect } = require("@playwright/test");
const { resolveTimeout } = require("../timeouts");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { skipUnlessServiceEnabled } = require("../service-gating");
const { fetchPluginRecord, loginAsAdministrator } = require("../jenkins-login");
test.use({ ignoreHTTPSErrors: true });
test("addon gitlab-plugin: the GitLab plugin is installed and active", async ({ page }) => {
skipUnlessAddonEnabled("gitlab-plugin");
skipUnlessServiceEnabled("gitlab");
test.setTimeout(resolveTimeout(180_000));
await loginAsAdministrator(page);
const plugin = await fetchPluginRecord(page, "gitlab-plugin");
expect(
plugin,
"the plugin manager must list 'gitlab-plugin'; it is absent when the addon declaration did " +
"not reach templates/plugins.txt.j2 and jenkins-plugin-cli never fetched it",
).toBeTruthy();
expect(plugin.active, "the GitLab plugin must be active, not merely downloaded").toBe(true);
expect(plugin.enabled, "the GitLab plugin must be enabled in the controller").toBe(true);
});
@@ -0,0 +1,24 @@
const { test, expect } = require("@playwright/test");
const { resolveTimeout } = require("../timeouts");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { skipUnlessServiceEnabled } = require("../service-gating");
const { fetchPluginRecord, loginAsAdministrator } = require("../jenkins-login");
test.use({ ignoreHTTPSErrors: true });
test("addon mattermost: the Mattermost notifier plugin is installed and active", async ({ page }) => {
skipUnlessAddonEnabled("mattermost");
skipUnlessServiceEnabled("mattermost");
test.setTimeout(resolveTimeout(180_000));
await loginAsAdministrator(page);
const plugin = await fetchPluginRecord(page, "mattermost");
expect(
plugin,
"the plugin manager must list 'mattermost'; it is absent when the addon declaration did not " +
"reach templates/plugins.txt.j2 and jenkins-plugin-cli never fetched it",
).toBeTruthy();
expect(plugin.active, "the Mattermost plugin must be active, not merely downloaded").toBe(true);
expect(plugin.enabled, "the Mattermost plugin must be enabled in the controller").toBe(true);
});
@@ -0,0 +1,32 @@
const { test, expect } = require("@playwright/test");
const { resolveTimeout } = require("../timeouts");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { skipUnlessServiceEnabled } = require("../service-gating");
const { gotoOnion } = require("../personas");
const { baseUrl, fetchPluginRecord, loginAsAdministrator } = require("../jenkins-login");
test.use({ ignoreHTTPSErrors: true });
test("addon oic-auth: the OpenID Connect plugin backs the active security realm", async ({ page }) => {
skipUnlessAddonEnabled("oic-auth");
skipUnlessServiceEnabled("sso");
test.setTimeout(resolveTimeout(180_000));
await loginAsAdministrator(page);
const plugin = await fetchPluginRecord(page, "oic-auth");
expect(
plugin,
"the plugin manager must list 'oic-auth'; it is absent when the addon declaration did not " +
"reach templates/plugins.txt.j2 and jenkins-plugin-cli never fetched it",
).toBeTruthy();
expect(plugin.active, "the oic-auth plugin must be active, not merely downloaded").toBe(true);
await gotoOnion(page, `${baseUrl}/manage/configureSecurity/`, { waitUntil: "domcontentloaded" });
const body = (await page.locator("body").innerText().catch(() => "")) || "";
expect(
body,
"the security configuration must offer the OpenID Connect realm; its absence means casc " +
"selected a realm the plugin does not provide",
).toMatch(/openid/i);
});
@@ -0,0 +1,39 @@
const { test, expect } = require("@playwright/test");
const { resolveTimeout } = require("../timeouts");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { baseUrl, fetchPluginRecord, loginAsAdministrator } = require("../jenkins-login");
test.use({ ignoreHTTPSErrors: true });
test("addon prometheus: the metrics plugin is active and its endpoint is not public", async ({ page, playwright }) => {
skipUnlessAddonEnabled("prometheus");
test.setTimeout(resolveTimeout(180_000));
await loginAsAdministrator(page);
const plugin = await fetchPluginRecord(page, "prometheus");
expect(
plugin,
"the plugin manager must list 'prometheus'; it is absent when the addon declaration did not " +
"reach templates/plugins.txt.j2 and jenkins-plugin-cli never fetched it",
).toBeTruthy();
expect(plugin.active, "the prometheus plugin must be active, not merely downloaded").toBe(true);
const anonymous = await playwright.request.newContext({ ignoreHTTPSErrors: true });
const metrics = await anonymous.get(`${baseUrl}/prometheus`, {
timeout: resolveTimeout(60_000),
});
const body = await metrics.text();
await anonymous.dispose();
expect(
metrics.status(),
`an anonymous visitor must not read the scrape endpoint on the public vhost; web-app-prometheus ` +
`reaches it on the internal container port instead (see templates/prometheus.yml.j2). Got ` +
`${metrics.status()} with ${body.length} bytes`,
).toBeGreaterThanOrEqual(400);
expect(
body,
"an anonymous refusal must not leak the metrics it refuses",
).not.toMatch(/^#\s*HELP/m);
});
@@ -0,0 +1,11 @@
---
enabled: "{{ lookup('config', 'web-app-jenkins', 'services.gitea.enabled') | bool }}"
required: true
mechanism: plugin
source: upstream
version: "latest"
bridges:
- gitea
config:
plugin_id: gitea
server_url: "{{ lookup('tls', 'web-app-gitea', 'url.base') }}"
@@ -0,0 +1,11 @@
---
enabled: "{{ lookup('config', 'web-app-jenkins', 'services.gitlab.enabled') | bool }}"
required: true
mechanism: plugin
source: upstream
version: "latest"
bridges:
- gitlab
config:
plugin_id: gitlab-plugin
server_url: "{{ lookup('tls', 'web-app-gitlab', 'url.base') }}"
@@ -0,0 +1,11 @@
---
enabled: "{{ lookup('config', 'web-app-jenkins', 'services.mattermost.enabled') | bool }}"
required: true
mechanism: plugin
source: upstream
version: "latest"
bridges:
- mattermost
config:
plugin_id: mattermost
server_url: "{{ lookup('tls', 'web-app-mattermost', 'url.base') }}"
@@ -0,0 +1,11 @@
---
enabled: "{{ lookup('config', 'web-app-jenkins', 'services.sso.enabled') | bool }}"
required: true
mechanism: plugin
source: upstream
version: "latest"
bridges:
- sso
config:
plugin_id: oic-auth
server_url: "{{ lookup('tls', 'web-app-keycloak', 'url.base') }}"
@@ -0,0 +1,11 @@
---
enabled: "{{ lookup('config', 'web-app-jenkins', 'services.prometheus.enabled') | bool }}"
required: true
mechanism: plugin
source: upstream
version: "latest"
bridges:
- prometheus
config:
plugin_id: prometheus
metrics_path: "/prometheus"
@@ -0,0 +1 @@
---
@@ -0,0 +1 @@
---
@@ -0,0 +1 @@
---
@@ -0,0 +1 @@
---
@@ -0,0 +1 @@
---
@@ -10,6 +10,10 @@ BIBER_USERNAME={{ lookup('users', 'biber').username | dotenv_quote }}
CANONICAL_DOMAIN={{ lookup('domain', application_id) | dotenv_quote }}
GITEA_SERVICE_ENABLED={{ lookup('config', application_id, 'services.gitea.enabled') | string | lower }}
GITLAB_SERVICE_ENABLED={{ lookup('config', application_id, 'services.gitlab.enabled') | string | lower }}
JENKINS_BASE_URL={{ lookup('tls', application_id, 'url.base') | dotenv_quote }}
LDAP_SERVICE_ENABLED={{ JENKINS_LDAP_ENABLED | string | lower }}
@@ -20,6 +24,8 @@ MATOMO_BASE_URL={{ lookup('tls', 'web-app-matomo', 'url.base') | dotenv_quote }}
MATOMO_SERVICE_ENABLED={{ lookup('config', application_id, 'services.matomo.enabled') | string | lower }}
MATTERMOST_SERVICE_ENABLED={{ lookup('config', application_id, 'services.mattermost.enabled') | string | lower }}
MCP_ENDPOINT_PATH={{ lookup('config', application_id, 'mcp.endpoint.path') | dotenv_quote }}
MCP_SERVICE_ENABLED={{ JENKINS_MCP_ENABLED | string | lower }}
@@ -36,3 +42,5 @@ PERSONA_BIBER_BLOCKED=true
SSO_SERVICE_ENABLED={{ JENKINS_OIDC_ENABLED | string | lower }}
{{ lookup('addon_env_flags', application_id) }}
@@ -0,0 +1,50 @@
const { test, expect } = require("../onion-test");
const { resolveTimeout } = require("../timeouts");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { skipUnlessServiceEnabled } = require("../service-gating");
const { decodeDotenvQuotedValue } = require("../personas");
const shared = require("../_shared");
test.use({ ignoreHTTPSErrors: true });
const HOOKSHOT_BOT_LOCALPART = "hookshot";
test("hookshot-gitlab addon: the hookshot appservice bot is provisioned on the Synapse homeserver", async ({ request }) => {
skipUnlessAddonEnabled("hookshot-gitlab");
skipUnlessServiceEnabled("gitlab");
test.skip(
!(process.env.MATRIX_FLAVOR || "").toLowerCase().includes("ansible"),
"hookshot ships with the matrix-docker-ansible-deploy flavor only",
);
const plugins = JSON.parse(decodeDotenvQuotedValue(process.env.MATRIX_PLUGINS_JSON || "") || "{}");
test.skip(
!plugins.hookshot,
`services.matrix.plugins.hookshot is off, so MDAD renders no hookshot bridge (MATRIX_PLUGINS_JSON: ${JSON.stringify(plugins)})`,
);
test.setTimeout(resolveTimeout(60_000));
const matrixBaseUrl = shared.env.matrixBaseUrl;
const matrixServerName = shared.env.matrixServerName;
expect(matrixBaseUrl, "MATRIX_BASE_URL must be set").toBeTruthy();
expect(matrixServerName, "MATRIX_SERVER_NAME must be set").toBeTruthy();
const botUserId = `@${HOOKSHOT_BOT_LOCALPART}:${matrixServerName}`;
const profileUrl = `${matrixBaseUrl}/_matrix/client/v3/profile/${encodeURIComponent(botUserId)}`;
const response = await request.get(profileUrl, { failOnStatusCode: false, timeout: resolveTimeout(30_000) });
expect(
response.status(),
`the hookshot appservice bot ${botUserId} must be registered on Synapse (${matrixBaseUrl}) so a GitLab room ` +
`connection can be opened. A 404 means the appservice registration never landed in the homeserver, so the ` +
`GitLab coupling failed to provision. A 5xx means the homeserver is unhealthy. Got HTTP ${response.status()}.`,
).toBe(200);
const profile = await response.json().catch(() => null);
expect(
profile && typeof profile === "object" && !Array.isArray(profile),
`Synapse must return the hookshot bot's profile JSON object for ${botUserId}, proving the appservice bot user exists`,
).toBe(true);
});
@@ -0,0 +1,50 @@
const { test, expect } = require("../onion-test");
const { resolveTimeout } = require("../timeouts");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { skipUnlessServiceEnabled } = require("../service-gating");
const { decodeDotenvQuotedValue } = require("../personas");
const shared = require("../_shared");
test.use({ ignoreHTTPSErrors: true });
const HOOKSHOT_BOT_LOCALPART = "hookshot";
test("hookshot-jira addon: the hookshot appservice bot is provisioned on the Synapse homeserver", async ({ request }) => {
skipUnlessAddonEnabled("hookshot-jira");
skipUnlessServiceEnabled("jira");
test.skip(
!(process.env.MATRIX_FLAVOR || "").toLowerCase().includes("ansible"),
"hookshot ships with the matrix-docker-ansible-deploy flavor only",
);
const plugins = JSON.parse(decodeDotenvQuotedValue(process.env.MATRIX_PLUGINS_JSON || "") || "{}");
test.skip(
!plugins.hookshot,
`services.matrix.plugins.hookshot is off, so MDAD renders no hookshot bridge (MATRIX_PLUGINS_JSON: ${JSON.stringify(plugins)})`,
);
test.setTimeout(resolveTimeout(60_000));
const matrixBaseUrl = shared.env.matrixBaseUrl;
const matrixServerName = shared.env.matrixServerName;
expect(matrixBaseUrl, "MATRIX_BASE_URL must be set").toBeTruthy();
expect(matrixServerName, "MATRIX_SERVER_NAME must be set").toBeTruthy();
const botUserId = `@${HOOKSHOT_BOT_LOCALPART}:${matrixServerName}`;
const profileUrl = `${matrixBaseUrl}/_matrix/client/v3/profile/${encodeURIComponent(botUserId)}`;
const response = await request.get(profileUrl, { failOnStatusCode: false, timeout: resolveTimeout(30_000) });
expect(
response.status(),
`the hookshot appservice bot ${botUserId} must be registered on Synapse (${matrixBaseUrl}) so a Jira room ` +
`connection can be opened. A 404 means the appservice registration never landed in the homeserver, so the ` +
`Jira coupling failed to provision. A 5xx means the homeserver is unhealthy. Got HTTP ${response.status()}.`,
).toBe(200);
const profile = await response.json().catch(() => null);
expect(
profile && typeof profile === "object" && !Array.isArray(profile),
`Synapse must return the hookshot bot's profile JSON object for ${botUserId}, proving the appservice bot user exists`,
).toBe(true);
});
@@ -0,0 +1,49 @@
const { test, expect } = require("../onion-test");
const { resolveTimeout } = require("../timeouts");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { skipUnlessServiceEnabled } = require("../service-gating");
const { decodeDotenvQuotedValue } = require("../personas");
const shared = require("../_shared");
test.use({ ignoreHTTPSErrors: true });
test("jitsi addon: Element advertises the co-deployed Jitsi deployment as its preferred domain", async ({ request }) => {
skipUnlessAddonEnabled("jitsi");
skipUnlessServiceEnabled("jitsi");
test.skip(
!(process.env.MATRIX_FLAVOR || "").toLowerCase().includes("compose"),
"the ansible flavor lets matrix-docker-ansible-deploy render Element's config.json",
);
test.setTimeout(resolveTimeout(60_000));
const preferredDomain = decodeDotenvQuotedValue(process.env.JITSI_PREFERRED_DOMAIN);
expect(preferredDomain, "JITSI_PREFERRED_DOMAIN must be set while the jitsi addon is enabled").toBeTruthy();
const { elementBaseUrl } = shared.env;
const response = await request.get(`${elementBaseUrl}/config.json`, {
failOnStatusCode: false,
timeout: resolveTimeout(30_000),
});
expect(response.status(), `Element /config.json must serve from ${elementBaseUrl}`).toBe(200);
const body = await response.text();
let config;
try {
config = JSON.parse(body);
} catch (e) {
throw new Error(`Element /config.json must be valid JSON: ${e.message}`, { cause: e });
}
expect(
config.jitsi && config.jitsi.preferredDomain,
`Element /config.json must carry jitsi.preferredDomain while the jitsi addon is enabled; without it every ` +
`conference widget falls back to the public meet.jit.si instead of the co-deployed web-app-jitsi. ` +
`Got keys: ${Object.keys(config).join(", ")}`,
).toBeTruthy();
expect(
config.jitsi.preferredDomain,
`Element must point at the co-deployed Jitsi domain, not a third-party instance`,
).toBe(preferredDomain);
});
@@ -0,0 +1,7 @@
---
enabled: "{{ lookup('config', 'web-app-matrix', 'services.gitlab.enabled') | bool }}"
required: false
mechanism: bridge
source: upstream
bridges:
- gitlab
@@ -0,0 +1,7 @@
---
enabled: "{{ lookup('config', 'web-app-matrix', 'services.jira.enabled') | bool }}"
required: false
mechanism: bridge
source: upstream
bridges:
- jira
@@ -0,0 +1,9 @@
---
enabled: "{{ lookup('config', 'web-app-matrix', 'services.jitsi.enabled') | bool }}"
required: false
mechanism: module
source: bundled
bridges:
- jitsi
config:
preferred_domain: "{{ lookup('domain', 'web-app-jitsi') }}"
@@ -0,0 +1 @@
---
@@ -0,0 +1 @@
---
@@ -0,0 +1 @@
---
@@ -19,6 +19,17 @@ ELEMENT_CALL_SERVICE_ENABLED={{ MATRIX_CONFERENCING_ELEMENT_CALL | string | lowe
# nocheck: playwright-service-gate — covered by sys-svc-mail integration tests, not the persona surface
EMAIL_SERVICE_ENABLED={{ lookup('config', application_id, 'services.email.enabled') | string | lower }}
# nocheck: playwright-service-gate — gated by files/playwright/addons/hookshot-gitlab.spec.js, which this check does not scan
GITLAB_SERVICE_ENABLED={{ lookup('config', application_id, 'services.gitlab.enabled') | string | lower }}
# nocheck: playwright-service-gate — gated by files/playwright/addons/hookshot-jira.spec.js, which this check does not scan
JIRA_SERVICE_ENABLED={{ lookup('config', application_id, 'services.jira.enabled') | string | lower }}
JITSI_PREFERRED_DOMAIN={{ (MATRIX_ADDON_JITSI_PREFERRED_DOMAIN if (lookup('config', application_id, 'services.jitsi.enabled') | bool) else '') | dotenv_quote }}
# nocheck: playwright-service-gate — gated by files/playwright/addons/jitsi.spec.js, which this check does not scan
JITSI_SERVICE_ENABLED={{ lookup('config', application_id, 'services.jitsi.enabled') | string | lower }}
# nocheck: playwright-service-gate — persona drives OIDC path; LDAP federation exercised in LDAP-only matrix variant
LDAP_SERVICE_ENABLED={{ lookup('config', application_id, 'services.ldap.enabled') | string | lower }}
@@ -60,6 +71,8 @@ SEAWEEDFS_SERVICE_ENABLED={{ lookup('config', application_id, 'services.seaweedf
SSO_SERVICE_ENABLED={{ lookup('config', application_id, 'services.sso.enabled') | string | lower }}
{{ lookup('addon_env_flags', application_id) }}
{% for bridge in MATRIX_BRIDGES | default([], true) %}
MAUTRIX_{{ bridge.bridge_name | upper | regex_replace('[^A-Z0-9]+', '_') }}_ADDON_ENABLED=true
{% endfor %}
@@ -0,0 +1,35 @@
const { expect } = require("@playwright/test");
const { resolveTimeout } = require("../timeouts");
const { performKeycloakLoginForm } = require("../personas");
const shared = require("../_shared");
const SESSION_HEADERS = { "X-Requested-With": "XMLHttpRequest" };
async function activePluginIds(page) {
const baseUrl = shared.expectedMattermostBaseUrl();
await shared.startMattermostSsoFlow(page, baseUrl);
await expect
.poll(() => page.url(), {
timeout: resolveTimeout(30_000),
message: `Expected redirect to Keycloak OIDC: ${shared.expectedOidcAuthUrl()}`,
})
.toContain(shared.expectedOidcAuthUrl());
await performKeycloakLoginForm(page, shared.env.adminUsername, shared.env.adminPassword);
await shared.waitForMattermostChannelView(page, resolveTimeout(120_000));
const response = await page.request.get(`${baseUrl}/api/v4/plugins`, {
headers: SESSION_HEADERS,
failOnStatusCode: false,
});
expect(
response.status(),
"the administrator session must be allowed to read /api/v4/plugins; a non-200 means the OIDC login did not land a system-admin session",
).toBe(200);
const body = await response.json();
return (body.active || []).map((manifest) => manifest.id);
}
module.exports = { activePluginIds };
@@ -0,0 +1,22 @@
const { test, expect } = require("@playwright/test");
const { resolveTimeout } = require("../timeouts");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { skipUnlessServiceEnabled } = require("../service-gating");
const { activePluginIds } = require("./_plugins");
const PLUGIN_ID = "mattermost-ai";
test.use({ ignoreHTTPSErrors: true });
test("addon mattermost-plugin-agents: the prepackaged Agents plugin is active", async ({ page }) => {
skipUnlessAddonEnabled("mattermost-plugin-agents");
skipUnlessServiceEnabled("sso");
test.setTimeout(resolveTimeout(240_000));
const active = await activePluginIds(page);
expect(
active,
`${PLUGIN_ID} must be among the active plugins; the plugin ships with the image, so its absence means the enable step in tasks/utils/addon.yml never ran`,
).toContain(PLUGIN_ID);
});
@@ -0,0 +1,22 @@
const { test, expect } = require("@playwright/test");
const { resolveTimeout } = require("../timeouts");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { skipUnlessServiceEnabled } = require("../service-gating");
const { activePluginIds } = require("./_plugins");
const PLUGIN_ID = "alertmanager";
test.use({ ignoreHTTPSErrors: true });
test("addon mattermost-plugin-alertmanager: the Alertmanager plugin is installed and active", async ({ page }) => {
skipUnlessAddonEnabled("mattermost-plugin-alertmanager");
skipUnlessServiceEnabled("sso");
test.setTimeout(resolveTimeout(240_000));
const active = await activePluginIds(page);
expect(
active,
`${PLUGIN_ID} must be among the active plugins; its absence means the marketplace install in tasks/utils/addon.yml was skipped or refused and alerts reach no channel`,
).toContain(PLUGIN_ID);
});
@@ -0,0 +1,22 @@
const { test, expect } = require("@playwright/test");
const { resolveTimeout } = require("../timeouts");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { skipUnlessServiceEnabled } = require("../service-gating");
const { activePluginIds } = require("./_plugins");
const PLUGIN_ID = "com.github.manland.mattermost-plugin-gitlab";
test.use({ ignoreHTTPSErrors: true });
test("addon mattermost-plugin-gitlab: the GitLab plugin is installed and active", async ({ page }) => {
skipUnlessAddonEnabled("mattermost-plugin-gitlab");
skipUnlessServiceEnabled("sso");
test.setTimeout(resolveTimeout(240_000));
const active = await activePluginIds(page);
expect(
active,
`${PLUGIN_ID} must be among the active plugins; its absence means the marketplace install in tasks/utils/addon.yml was skipped or refused and the GitLab surface never loaded`,
).toContain(PLUGIN_ID);
});
@@ -0,0 +1,22 @@
const { test, expect } = require("@playwright/test");
const { resolveTimeout } = require("../timeouts");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { skipUnlessServiceEnabled } = require("../service-gating");
const { activePluginIds } = require("./_plugins");
const PLUGIN_ID = "jenkins";
test.use({ ignoreHTTPSErrors: true });
test("addon mattermost-plugin-jenkins: the Jenkins plugin is installed and active", async ({ page }) => {
skipUnlessAddonEnabled("mattermost-plugin-jenkins");
skipUnlessServiceEnabled("sso");
test.setTimeout(resolveTimeout(240_000));
const active = await activePluginIds(page);
expect(
active,
`${PLUGIN_ID} must be among the active plugins; its absence means the marketplace install in tasks/utils/addon.yml was skipped or refused and no /jenkins command is registered`,
).toContain(PLUGIN_ID);
});
@@ -0,0 +1,22 @@
const { test, expect } = require("@playwright/test");
const { resolveTimeout } = require("../timeouts");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { skipUnlessServiceEnabled } = require("../service-gating");
const { activePluginIds } = require("./_plugins");
const PLUGIN_ID = "jira";
test.use({ ignoreHTTPSErrors: true });
test("addon mattermost-plugin-jira: the Jira plugin is installed and active", async ({ page }) => {
skipUnlessAddonEnabled("mattermost-plugin-jira");
skipUnlessServiceEnabled("sso");
test.setTimeout(resolveTimeout(240_000));
const active = await activePluginIds(page);
expect(
active,
`${PLUGIN_ID} must be among the active plugins; its absence means the marketplace install in tasks/utils/addon.yml was skipped or refused and no instance can be registered`,
).toContain(PLUGIN_ID);
});
@@ -0,0 +1,22 @@
const { test, expect } = require("@playwright/test");
const { resolveTimeout } = require("../timeouts");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { skipUnlessServiceEnabled } = require("../service-gating");
const { activePluginIds } = require("./_plugins");
const PLUGIN_ID = "jitsi";
test.use({ ignoreHTTPSErrors: true });
test("addon mattermost-plugin-jitsi: the Jitsi plugin is installed and active", async ({ page }) => {
skipUnlessAddonEnabled("mattermost-plugin-jitsi");
skipUnlessServiceEnabled("sso");
test.setTimeout(resolveTimeout(240_000));
const active = await activePluginIds(page);
expect(
active,
`${PLUGIN_ID} must be among the active plugins; its absence means the marketplace install in tasks/utils/addon.yml was skipped or refused and no /jitsi command is registered`,
).toContain(PLUGIN_ID);
});
@@ -0,0 +1,32 @@
const { test, expect } = require("@playwright/test");
const { resolveTimeout } = require("../timeouts");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { skipUnlessServiceEnabled } = require("../service-gating");
const { gotoOnion } = require("../personas");
const shared = require("../_shared");
test.use({ ignoreHTTPSErrors: true });
test("addon saml: the licensed SAML entry point reaches the partner Keycloak", async ({ page }) => {
skipUnlessAddonEnabled("saml");
skipUnlessServiceEnabled("sso");
test.setTimeout(resolveTimeout(120_000));
const baseUrl = shared.expectedMattermostBaseUrl();
await gotoOnion(page, `${baseUrl}/login`, { waitUntil: "load", timeout: resolveTimeout(60_000) });
const samlEntry = page.locator("a[href='/login/sso/saml']");
await expect(
samlEntry,
"the SAML login entry point must render once the Enterprise licence activates this addon; without the licence the role stays on the OIDC path and this addon must remain disabled",
).toBeVisible({ timeout: resolveTimeout(30_000) });
const response = await page.request.get(`${baseUrl}/login/sso/saml`, {
failOnStatusCode: false,
maxRedirects: 0,
});
expect(
response.headers()["location"] || "",
"the SAML entry point must hand off to the partner Keycloak, not answer locally",
).toContain(new URL(shared.env.oidcIssuerUrl).host);
});
@@ -0,0 +1,10 @@
---
enabled: "{{ lookup('config', 'web-app-mattermost', 'services.openwebui.enabled') | bool }}"
required: false
mechanism: plugin
source: bundled
bridges:
- openwebui
config:
plugin_id: mattermost-ai
partner_url: "{{ lookup('tls', 'web-app-openwebui', 'url.base') | regex_replace('/+$', '') ~ '/api' }}"
@@ -0,0 +1,16 @@
---
enabled: false
required: false
mechanism: plugin
source: upstream
version: "v0.3.0"
bridges:
- prometheus
update:
monitored: true
catalog: github-releases
upstream_id: cpanato/mattermost-plugin-alertmanager
config:
plugin_id: alertmanager
partner_host: "{{ lookup('container_address', 'web-app-prometheus', 'alertmanager') }}"
partner_port: "{{ lookup('config', 'web-app-prometheus', 'services.alertmanager.ports.internal.http') }}"
@@ -0,0 +1,15 @@
---
enabled: "{{ lookup('config', 'web-app-mattermost', 'services.gitlab.enabled') | bool }}"
required: false
mechanism: plugin
source: upstream
version: "v2.2.0"
bridges:
- gitlab
update:
monitored: true
catalog: github-releases
upstream_id: mattermost/mattermost-plugin-gitlab
config:
plugin_id: com.github.manland.mattermost-plugin-gitlab
partner_url: "{{ lookup('tls', 'web-app-gitlab', 'url.base') }}"
@@ -0,0 +1,15 @@
---
enabled: "{{ lookup('config', 'web-app-mattermost', 'services.jenkins.enabled') | bool }}"
required: false
mechanism: plugin
source: upstream
version: "v1.1.0"
bridges:
- jenkins
update:
monitored: true
catalog: github-releases
upstream_id: mattermost/mattermost-plugin-jenkins
config:
plugin_id: jenkins
partner_url: "{{ lookup('tls', 'web-app-jenkins', 'url.base') }}"
Loaded 100 of 198 files, more files were not shown because too many files have changed in this diff. Show more