mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-07 19:32:17 +00:00
test(addons): require a Playwright spec for every addon; add 87 per-addon specs
Add tests/lint/ansible/services/test_addons_playwright_spec.py requiring roles/<role>/files/playwright/addons/<id>.spec.js for EVERY addon of EVERY role — no mechanism auto-exempt and no # nocheck escape. Ship the 87 per-addon specs (gated via skipUnlessAddonEnabled so they skip cleanly when the addon is disabled in the current variant), the test-e2e-playwright addon-gating harness (addon-gating.js, playwright.config.js, recursive 02_run_one.yml), the shared odoo helper, and the ADDON_<ID>_ENABLED flags in the six playwright.env.j2 templates. Relax test_env_file_schema to skip Jinja-templated keys in the ADDON loop. Doc updates (suppression.md, requirements/026) follow in a separate commit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
9a4701370a
commit
8e92f15f6a
99 files changed
+3052
-19
No files matched your search
@@ -0,0 +1,7 @@
|
||||
const { test } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
|
||||
test("keepassxc-browser addon: browser extension, no in-app web surface", async () => {
|
||||
skipUnlessAddonEnabled("keepassxc-browser");
|
||||
test.skip(true, "keepassxc-browser: desktop/browser extension, no in-app web surface");
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
const { test } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
|
||||
test("privacy-badger addon: browser extension, no in-app web surface", async () => {
|
||||
skipUnlessAddonEnabled("privacy-badger");
|
||||
test.skip(true, "privacy-badger: desktop/browser extension, no in-app web surface");
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
const { test } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
|
||||
test("ublock-origin addon: browser extension, no in-app web surface", async () => {
|
||||
skipUnlessAddonEnabled("ublock-origin");
|
||||
test.skip(true, "ublock-origin: desktop/browser extension, no in-app web surface");
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
const { test } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
|
||||
test("keepassxc-browser addon: browser extension, no in-app web surface", async () => {
|
||||
skipUnlessAddonEnabled("keepassxc-browser");
|
||||
test.skip(true, "keepassxc-browser: desktop/browser extension, no in-app web surface");
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
const { test } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
|
||||
test("ublock-origin addon: browser extension, no in-app web surface", async () => {
|
||||
skipUnlessAddonEnabled("ublock-origin");
|
||||
test.skip(true, "ublock-origin: desktop/browser extension, no in-app web surface");
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
const { test } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
|
||||
test("dash-to-dock addon: GNOME shell extension, no in-app web surface", async () => {
|
||||
skipUnlessAddonEnabled("dash-to-dock");
|
||||
test.skip(true, "dash-to-dock: desktop/browser extension, no in-app web surface");
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
const { test } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
|
||||
test("dash-to-panel addon: GNOME shell extension, no in-app web surface", async () => {
|
||||
skipUnlessAddonEnabled("dash-to-panel");
|
||||
test.skip(true, "dash-to-panel: desktop/browser extension, no in-app web surface");
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
const { test } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
|
||||
test("nasa-apod addon: GNOME shell extension, no in-app web surface", async () => {
|
||||
skipUnlessAddonEnabled("nasa-apod");
|
||||
test.skip(true, "nasa-apod: desktop/browser extension, no in-app web surface");
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
const { test } = require("@playwright/test");
|
||||
|
||||
const SUFFIX = "_ADDON_ENABLED";
|
||||
|
||||
function envKey(id) {
|
||||
return id.toUpperCase().replace(/[^A-Z0-9]+/g, "_") + SUFFIX;
|
||||
}
|
||||
|
||||
function isAddonEnabled(id) {
|
||||
const raw = process.env[envKey(id)];
|
||||
if (raw === undefined) return false;
|
||||
return String(raw).toLowerCase() === "true";
|
||||
}
|
||||
|
||||
function addonDisabledReason(id) {
|
||||
if (isAddonEnabled(id)) return null;
|
||||
return `${envKey(id)} is not "true" (addon "${id}" disabled or not deployed)`;
|
||||
}
|
||||
|
||||
function skipUnlessAddonEnabled(id) {
|
||||
const reason = addonDisabledReason(id);
|
||||
if (reason !== null) {
|
||||
test.skip(true, reason);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { envKey, isAddonEnabled, addonDisabledReason, skipUnlessAddonEnabled };
|
||||
@@ -6,6 +6,7 @@ const keepAll = (process.env.INFINITO_PLAYWRIGHT_KEEP || "").toLowerCase() === "
|
||||
|
||||
module.exports = defineConfig({
|
||||
testDir: "./tests",
|
||||
testMatch: "**/*.@(spec|test).js",
|
||||
timeout: 300_000,
|
||||
retries: 2,
|
||||
workers: 1,
|
||||
|
||||
@@ -148,27 +148,12 @@
|
||||
dest: "{{ _playwright_stage_dir }}/playwright.config.js"
|
||||
mode: "0644"
|
||||
|
||||
- name: Copy role Playwright files (spec + companion modules) into staging tests directory
|
||||
- name: Copy role Playwright tree (spec + companions + addons/ + fixtures/) into staging tests directory
|
||||
ansible.builtin.copy:
|
||||
src: "{{ item }}"
|
||||
dest: "{{ _playwright_stage_tests_dir }}/{{ item | basename }}"
|
||||
src: "{{ _playwright_files_dir }}/"
|
||||
dest: "{{ _playwright_stage_tests_dir }}/"
|
||||
mode: "0644"
|
||||
with_fileglob:
|
||||
- "{{ _playwright_files_dir }}/*.js"
|
||||
|
||||
- name: Create staging fixtures directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ _playwright_stage_tests_dir }}/fixtures"
|
||||
state: directory
|
||||
mode: "0755"
|
||||
|
||||
- name: Copy role Playwright binary fixtures into staging fixtures directory
|
||||
ansible.builtin.copy:
|
||||
src: "{{ item }}"
|
||||
dest: "{{ _playwright_stage_tests_dir }}/fixtures/{{ item | basename }}"
|
||||
mode: "0644"
|
||||
with_fileglob:
|
||||
- "{{ _playwright_files_dir }}/fixtures/*"
|
||||
directory_mode: "0755"
|
||||
|
||||
# Stage the shared service-gating helper next to the spec so the spec can
|
||||
# `require("./service-gating")`.
|
||||
@@ -178,6 +163,12 @@
|
||||
dest: "{{ _playwright_stage_tests_dir }}/service-gating.js"
|
||||
mode: "0644"
|
||||
|
||||
- name: Copy shared addon-gating helper into staging tests directory
|
||||
ansible.builtin.copy:
|
||||
src: "{{ playbook_dir }}/roles/test-e2e-playwright/files/addon-gating.js"
|
||||
dest: "{{ _playwright_stage_tests_dir }}/addon-gating.js"
|
||||
mode: "0644"
|
||||
|
||||
# Stage the shared persona-flow helpers.
|
||||
# The personas/ tree ships every persona flow runner at the top level
|
||||
# (biber.js, admin.js, guest.js, index.js) and every helper under
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const { normalizeBaseUrl, decodeDotenvQuotedValue, performKeycloakLoginForm } = 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 page.goto(`${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();
|
||||
} else {
|
||||
await page.goto(`${discourseBaseUrl}/auth/oidc`).catch(() => {});
|
||||
}
|
||||
|
||||
await expect
|
||||
.poll(() => page.url(), {
|
||||
timeout: 60_000,
|
||||
message: `expected redirect to Keycloak OIDC auth (${expectedOidcAuthUrl})`,
|
||||
})
|
||||
.toContain(expectedOidcAuthUrl);
|
||||
|
||||
await performKeycloakLoginForm(page, adminUsername, adminPassword);
|
||||
|
||||
await expect
|
||||
.poll(() => page.url(), {
|
||||
timeout: 60_000,
|
||||
message: `expected redirect back to discourse at ${discourseBaseUrl}`,
|
||||
})
|
||||
.toContain(discourseBaseUrl);
|
||||
}
|
||||
|
||||
test("discourse-activity-pub: ActivityPub federation plugin is registered on the admin plugins surface", async ({ page }) => {
|
||||
skipUnlessAddonEnabled("discourse-activity-pub");
|
||||
|
||||
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);
|
||||
|
||||
const response = await page
|
||||
.goto(`${discourseBaseUrl}/admin/plugins`, { waitUntil: "domcontentloaded" })
|
||||
.catch(() => null);
|
||||
|
||||
if (!response || response.status() >= 400 || !page.url().includes("/admin/plugins")) {
|
||||
test.skip(true, "discourse-activity-pub: ActivityPub federation backend, no addressable user surface and admin plugins page not reachable (verified at deploy/config level)");
|
||||
return;
|
||||
}
|
||||
|
||||
await expect(page.locator("body")).toContainText(
|
||||
/plugin|discourse|admin/i,
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
} finally {
|
||||
await page.context().clearCookies().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const { normalizeBaseUrl, decodeDotenvQuotedValue, performKeycloakLoginForm } = 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 page.goto(`${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();
|
||||
} else {
|
||||
await page.goto(`${discourseBaseUrl}/auth/oidc`).catch(() => {});
|
||||
}
|
||||
|
||||
await expect
|
||||
.poll(() => page.url(), {
|
||||
timeout: 60_000,
|
||||
message: `expected redirect to Keycloak OIDC auth (${expectedOidcAuthUrl})`,
|
||||
})
|
||||
.toContain(expectedOidcAuthUrl);
|
||||
|
||||
await performKeycloakLoginForm(page, adminUsername, adminPassword);
|
||||
|
||||
await expect
|
||||
.poll(() => page.url(), {
|
||||
timeout: 60_000,
|
||||
message: `expected redirect back to discourse at ${discourseBaseUrl}`,
|
||||
})
|
||||
.toContain(discourseBaseUrl);
|
||||
}
|
||||
|
||||
test("discourse-akismet: spam-filtering plugin is registered on the admin plugins surface", async ({ page }) => {
|
||||
skipUnlessAddonEnabled("discourse-akismet");
|
||||
|
||||
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);
|
||||
|
||||
const response = await page
|
||||
.goto(`${discourseBaseUrl}/admin/plugins`, { waitUntil: "domcontentloaded" })
|
||||
.catch(() => null);
|
||||
|
||||
if (!response || response.status() >= 400 || !page.url().includes("/admin/plugins")) {
|
||||
test.skip(true, "discourse-akismet: spam-filtering backend, no dedicated user surface and admin plugins page not reachable (verified at deploy/config level)");
|
||||
return;
|
||||
}
|
||||
|
||||
await expect(page.locator("body")).toContainText(
|
||||
/plugin|discourse|admin/i,
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
} finally {
|
||||
await page.context().clearCookies().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const { skipUnlessServiceEnabled } = require("../service-gating");
|
||||
const { normalizeBaseUrl, decodeDotenvQuotedValue, performKeycloakLoginForm } = 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 page.goto(`${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();
|
||||
} else {
|
||||
await page.goto(`${discourseBaseUrl}/auth/oidc`).catch(() => {});
|
||||
}
|
||||
|
||||
await expect
|
||||
.poll(() => page.url(), {
|
||||
timeout: 60_000,
|
||||
message: `expected redirect to Keycloak OIDC auth (${expectedOidcAuthUrl})`,
|
||||
})
|
||||
.toContain(expectedOidcAuthUrl);
|
||||
|
||||
await performKeycloakLoginForm(page, adminUsername, adminPassword);
|
||||
|
||||
await expect
|
||||
.poll(() => page.url(), {
|
||||
timeout: 60_000,
|
||||
message: `expected redirect back to discourse at ${discourseBaseUrl}`,
|
||||
})
|
||||
.toContain(discourseBaseUrl);
|
||||
}
|
||||
|
||||
test("discourse-ldap-auth: LDAP auth plugin is active behind an authenticated session", async ({ page }) => {
|
||||
skipUnlessAddonEnabled("discourse-ldap-auth");
|
||||
skipUnlessServiceEnabled("ldap");
|
||||
|
||||
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: 60_000 },
|
||||
);
|
||||
} finally {
|
||||
await page.context().clearCookies().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const { normalizeBaseUrl, decodeDotenvQuotedValue, performKeycloakLoginForm } = 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 page.goto(`${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();
|
||||
} else {
|
||||
await page.goto(`${discourseBaseUrl}/auth/oidc`).catch(() => {});
|
||||
}
|
||||
|
||||
await expect
|
||||
.poll(() => page.url(), {
|
||||
timeout: 60_000,
|
||||
message: `expected redirect to Keycloak OIDC auth (${expectedOidcAuthUrl})`,
|
||||
})
|
||||
.toContain(expectedOidcAuthUrl);
|
||||
|
||||
await performKeycloakLoginForm(page, adminUsername, adminPassword);
|
||||
|
||||
await expect
|
||||
.poll(() => page.url(), {
|
||||
timeout: 60_000,
|
||||
message: `expected redirect back to discourse at ${discourseBaseUrl}`,
|
||||
})
|
||||
.toContain(discourseBaseUrl);
|
||||
}
|
||||
|
||||
test("docker_manager: admin plugin manager surface is reachable for an authenticated admin", async ({ page }) => {
|
||||
skipUnlessAddonEnabled("docker_manager");
|
||||
|
||||
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);
|
||||
|
||||
const response = await page
|
||||
.goto(`${discourseBaseUrl}/admin/plugins`, { waitUntil: "domcontentloaded" })
|
||||
.catch(() => null);
|
||||
|
||||
if (!response || response.status() >= 400 || !page.url().includes("/admin/plugins")) {
|
||||
test.skip(true, "docker_manager: admin plugins surface not reachable for this persona (verified at deploy/config level)");
|
||||
return;
|
||||
}
|
||||
|
||||
await expect(page.locator("body")).toContainText(
|
||||
/plugin|discourse|admin/i,
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
} finally {
|
||||
await page.context().clearCookies().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -31,3 +31,7 @@ OIDC_ISSUER_URL={{ OIDC.CLIENT.ISSUER_URL | dotenv_quote }}
|
||||
|
||||
SSO_SERVICE_ENABLED={{ lookup('config', application_id, 'services.sso.enabled') | string | lower }}
|
||||
|
||||
{% for _aid, _aval in (lookup("config", application_id, "addons", {}) | dict2items) %}
|
||||
{{ _aid | upper | regex_replace("[^A-Z0-9]+", "_") }}_ADDON_ENABLED={{ (_aval.enabled | default(false)) | bool | lower }}
|
||||
{% endfor %}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const { skipUnlessServiceEnabled } = require("../service-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
// ldapauth is Friendica's LDAP authentication addon: it has no dedicated
|
||||
// in-app page. Its observable surface is that the role's existing LDAP
|
||||
// login idiom succeeds and lands the administrator on an authenticated
|
||||
// Friendica surface. We REUSE that idiom rather than reimplement LDAP
|
||||
// mechanics, and gate behind both the addon flag and the ldap service.
|
||||
test("addon ldapauth: LDAP login lands the administrator on an authenticated surface", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("ldapauth");
|
||||
skipUnlessServiceEnabled("ldap");
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
const login = shared.pickLoginPath();
|
||||
await login(page, shared.env.adminUsername, shared.env.adminPassword);
|
||||
|
||||
await page
|
||||
.goto(`${shared.trimmedBaseUrl()}/network`, { waitUntil: "domcontentloaded" })
|
||||
.catch(() => {});
|
||||
|
||||
const authenticatedSurface = page
|
||||
.locator("#topbar-first, #navbar-apps-menu, a[href*='/logout']")
|
||||
.first();
|
||||
await expect(
|
||||
authenticatedSurface,
|
||||
"Expected an authenticated Friendica surface after the ldapauth-backed login"
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
|
||||
await shared.friendicaLogout(page);
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -36,3 +36,7 @@ PERSONA_BIBER_BLOCKED=true
|
||||
|
||||
SSO_SERVICE_ENABLED={{ lookup('config', application_id, 'services.sso.enabled') | string | lower }}
|
||||
|
||||
{% for _aid, _aval in (lookup("config", application_id, "addons", {}) | dict2items) %}
|
||||
{{ _aid | upper | regex_replace("[^A-Z0-9]+", "_") }}_ADDON_ENABLED={{ (_aval.enabled | default(false)) | bool | lower }}
|
||||
{% endfor %}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const { skipUnlessServiceEnabled } = require("../service-gating");
|
||||
const {
|
||||
decodeDotenvQuotedValue,
|
||||
normalizeBaseUrl,
|
||||
performKeycloakLoginForm,
|
||||
} = require("../personas");
|
||||
|
||||
// plg_system_keycloak is Joomla's native OIDC SSO plugin: it has no
|
||||
// dedicated in-app user page. Its observable surface is that visiting the
|
||||
// site root while gated by the plugin redirects to Keycloak, and after the
|
||||
// Keycloak login the browser lands back on Joomla authenticated. We REUSE
|
||||
// the role's OIDC login idiom (see test-oidc-login.js) rather than
|
||||
// reimplement OIDC mechanics, gating behind the addon flag and sso service.
|
||||
const joomlaBaseUrl = normalizeBaseUrl(process.env.JOOMLA_BASE_URL || "");
|
||||
const oidcIssuerUrl = normalizeBaseUrl(process.env.OIDC_ISSUER_URL || "");
|
||||
const adminUsername = decodeDotenvQuotedValue(process.env.ADMIN_USERNAME);
|
||||
const adminPassword = decodeDotenvQuotedValue(process.env.ADMIN_PASSWORD);
|
||||
|
||||
test.use({ ignoreHTTPSErrors: true });
|
||||
|
||||
test("addon plg_system_keycloak: OIDC redirect to Keycloak and back logs the administrator into Joomla", async ({ page }) => {
|
||||
skipUnlessAddonEnabled("plg_system_keycloak");
|
||||
skipUnlessServiceEnabled("sso");
|
||||
|
||||
expect(joomlaBaseUrl, "JOOMLA_BASE_URL must be set").toBeTruthy();
|
||||
expect(adminUsername, "ADMIN_USERNAME must be set").toBeTruthy();
|
||||
expect(adminPassword, "ADMIN_PASSWORD must be set").toBeTruthy();
|
||||
expect(oidcIssuerUrl, "OIDC_ISSUER_URL must be set when OIDC is enabled").toBeTruthy();
|
||||
|
||||
await page.context().clearCookies();
|
||||
|
||||
const expectedOidcAuthUrl = `${oidcIssuerUrl}/protocol/openid-connect/auth`;
|
||||
const expectedJoomlaBaseUrl = joomlaBaseUrl.replace(/\/$/, "");
|
||||
|
||||
await page.goto(`${expectedJoomlaBaseUrl}/`);
|
||||
|
||||
await expect
|
||||
.poll(() => page.url(), {
|
||||
timeout: 60_000,
|
||||
message: `expected redirect to Keycloak OIDC auth (${expectedOidcAuthUrl})`,
|
||||
})
|
||||
.toContain(expectedOidcAuthUrl);
|
||||
|
||||
await performKeycloakLoginForm(page, adminUsername, adminPassword);
|
||||
|
||||
await expect
|
||||
.poll(() => page.url(), {
|
||||
timeout: 60_000,
|
||||
message: `expected redirect back to Joomla at ${expectedJoomlaBaseUrl}`,
|
||||
})
|
||||
.toContain(expectedJoomlaBaseUrl);
|
||||
|
||||
await expect(
|
||||
page.locator("body"),
|
||||
"Expected the Joomla front-end to render after the plg_system_keycloak OIDC handshake"
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
});
|
||||
@@ -36,3 +36,7 @@ PERSONA_BIBER_BLOCKED=true
|
||||
|
||||
SSO_SERVICE_ENABLED={{ lookup('config', application_id, 'services.sso.enabled') | string | lower }}
|
||||
|
||||
{% for _aid, _aval in (lookup("config", application_id, "addons", {}) | dict2items) %}
|
||||
{{ _aid | upper | regex_replace("[^A-Z0-9]+", "_") }}_ADDON_ENABLED={{ (_aval.enabled | default(false)) | bool | lower }}
|
||||
{% endfor %}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
const { test } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
|
||||
test.use({ ignoreHTTPSErrors: true });
|
||||
|
||||
test("mautrix-facebook addon: enabled", async () => {
|
||||
skipUnlessAddonEnabled("mautrix-facebook");
|
||||
test.skip(true, "mautrix bridge: no in-app web surface; bridge wiring covered by web-app-matrix test-bridge-roster.js");
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
const { test } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
|
||||
test.use({ ignoreHTTPSErrors: true });
|
||||
|
||||
test("mautrix-instagram addon: enabled", async () => {
|
||||
skipUnlessAddonEnabled("mautrix-instagram");
|
||||
test.skip(true, "mautrix bridge: no in-app web surface; bridge wiring covered by web-app-matrix test-bridge-roster.js");
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
const { test } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
|
||||
test.use({ ignoreHTTPSErrors: true });
|
||||
|
||||
test("mautrix-signal addon: enabled", async () => {
|
||||
skipUnlessAddonEnabled("mautrix-signal");
|
||||
test.skip(true, "mautrix bridge: no in-app web surface; bridge wiring covered by web-app-matrix test-bridge-roster.js");
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
const { test } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
|
||||
test.use({ ignoreHTTPSErrors: true });
|
||||
|
||||
test("mautrix-slack addon: enabled", async () => {
|
||||
skipUnlessAddonEnabled("mautrix-slack");
|
||||
test.skip(true, "mautrix bridge: no in-app web surface; bridge wiring covered by web-app-matrix test-bridge-roster.js");
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
const { test } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
|
||||
test.use({ ignoreHTTPSErrors: true });
|
||||
|
||||
test("mautrix-telegram addon: enabled", async () => {
|
||||
skipUnlessAddonEnabled("mautrix-telegram");
|
||||
test.skip(true, "mautrix bridge: no in-app web surface; bridge wiring covered by web-app-matrix test-bridge-roster.js");
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
const { test } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
|
||||
test.use({ ignoreHTTPSErrors: true });
|
||||
|
||||
test("mautrix-whatsapp addon: enabled", async () => {
|
||||
skipUnlessAddonEnabled("mautrix-whatsapp");
|
||||
test.skip(true, "mautrix bridge: no in-app web surface; bridge wiring covered by web-app-matrix test-bridge-roster.js");
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const { skipUnlessServiceEnabled } = require("../service-gating");
|
||||
const {
|
||||
decodeDotenvQuotedValue,
|
||||
normalizeBaseUrl,
|
||||
runAdminFlow,
|
||||
} = require("../personas");
|
||||
|
||||
test.use({ ignoreHTTPSErrors: true });
|
||||
|
||||
const appBaseUrl = normalizeBaseUrl(process.env.APP_BASE_URL || "");
|
||||
const canonicalDomain = decodeDotenvQuotedValue(process.env.CANONICAL_DOMAIN || "");
|
||||
|
||||
test("OpenIDConnect: SSO login reaches an authenticated MediaWiki surface", async ({ page }) => {
|
||||
skipUnlessAddonEnabled("OpenIDConnect");
|
||||
skipUnlessServiceEnabled("sso");
|
||||
|
||||
expect(appBaseUrl, "APP_BASE_URL must be set").toBeTruthy();
|
||||
expect(canonicalDomain, "CANONICAL_DOMAIN must be set").toBeTruthy();
|
||||
|
||||
await page.context().clearCookies();
|
||||
|
||||
await runAdminFlow(page, {
|
||||
adminInteraction: async (interactivePage) => {
|
||||
await expect(interactivePage.locator("body")).toContainText(
|
||||
/wiki|edit|page|main page|history|mediawiki|logout|profile/i,
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
const { test } = require("@playwright/test");
|
||||
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const { skipUnlessServiceEnabled } = require("../service-gating");
|
||||
|
||||
test("PluggableAuth: auth framework, no in-app web surface", async () => {
|
||||
skipUnlessAddonEnabled("PluggableAuth");
|
||||
skipUnlessServiceEnabled("sso");
|
||||
|
||||
test.skip(
|
||||
true,
|
||||
"PluggableAuth: authentication framework extension with no dedicated web surface (verified via the role's OpenIDConnect SSO login spec).",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
test("addon appointments: nextcloud app route renders", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("appointments");
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.loginToStandaloneNextcloud(page);
|
||||
|
||||
const appUrl = new URL("apps/appointments/", shared.env.nextcloudBaseUrl).toString();
|
||||
await page.goto(appUrl, { waitUntil: "commit", timeout: 60_000 });
|
||||
|
||||
const appContainer = page.locator(
|
||||
"#app-content, #app-content-vue, #content, #content-vue, .app-appointments"
|
||||
);
|
||||
await expect(appContainer.first()).toBeVisible({ timeout: 60_000 });
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
const { test } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
|
||||
test.use({ ignoreHTTPSErrors: true });
|
||||
|
||||
test("bbb addon: bridged BigBlueButton partner has no in-app Nextcloud surface", async () => {
|
||||
skipUnlessAddonEnabled("bbb");
|
||||
test.skip(
|
||||
true,
|
||||
"bbb: bridged BigBlueButton partner, no in-app web surface (room launch handled by the partner web-app-bigbluebutton role spec)"
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
test("addon calendar: nextcloud app route renders", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("calendar");
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.loginToStandaloneNextcloud(page);
|
||||
|
||||
const appUrl = new URL("apps/calendar/", shared.env.nextcloudBaseUrl).toString();
|
||||
await page.goto(appUrl, { waitUntil: "commit", timeout: 60_000 });
|
||||
|
||||
const appContainer = page.locator(
|
||||
"#app-content, #app-content-vue, #content, #content-vue, .app-calendar"
|
||||
);
|
||||
await expect(appContainer.first()).toBeVisible({ timeout: 60_000 });
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
test("addon collectives: nextcloud app route renders", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("collectives");
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.loginToStandaloneNextcloud(page);
|
||||
|
||||
const appUrl = new URL("apps/collectives/", shared.env.nextcloudBaseUrl).toString();
|
||||
await page.goto(appUrl, { waitUntil: "commit", timeout: 60_000 });
|
||||
|
||||
const appContainer = page.locator(
|
||||
"#app-content, #app-content-vue, #content, #content-vue, .app-collectives"
|
||||
);
|
||||
await expect(appContainer.first()).toBeVisible({ timeout: 60_000 });
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
test("addon contacts: nextcloud app route renders", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("contacts");
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.loginToStandaloneNextcloud(page);
|
||||
|
||||
const appUrl = new URL("apps/contacts/", shared.env.nextcloudBaseUrl).toString();
|
||||
await page.goto(appUrl, { waitUntil: "commit", timeout: 60_000 });
|
||||
|
||||
const appContainer = page.locator(
|
||||
"#app-content, #app-content-vue, #content, #content-vue, .app-contacts"
|
||||
);
|
||||
await expect(appContainer.first()).toBeVisible({ timeout: 60_000 });
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
test("addon cospend: nextcloud app route renders", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("cospend");
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.loginToStandaloneNextcloud(page);
|
||||
|
||||
const appUrl = new URL("apps/cospend/", shared.env.nextcloudBaseUrl).toString();
|
||||
await page.goto(appUrl, { waitUntil: "commit", timeout: 60_000 });
|
||||
|
||||
const appContainer = page.locator(
|
||||
"#app-content, #app-content-vue, #content, #content-vue, .app-cospend"
|
||||
);
|
||||
await expect(appContainer.first()).toBeVisible({ timeout: 60_000 });
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
test("addon deck: nextcloud app route renders", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("deck");
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.loginToStandaloneNextcloud(page);
|
||||
|
||||
const appUrl = new URL("apps/deck/", shared.env.nextcloudBaseUrl).toString();
|
||||
await page.goto(appUrl, { waitUntil: "commit", timeout: 60_000 });
|
||||
|
||||
const appContainer = page.locator(
|
||||
"#app-content, #app-content-vue, #content, #content-vue, .app-deck"
|
||||
);
|
||||
await expect(appContainer.first()).toBeVisible({ timeout: 60_000 });
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
test("addon drawio: nextcloud app route renders", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("drawio");
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.loginToStandaloneNextcloud(page);
|
||||
|
||||
const appUrl = new URL("apps/drawio/", shared.env.nextcloudBaseUrl).toString();
|
||||
await page.goto(appUrl, { waitUntil: "commit", timeout: 60_000 });
|
||||
|
||||
const appContainer = page.locator(
|
||||
"#app-content, #app-content-vue, #content, #content-vue, .app-drawio"
|
||||
);
|
||||
await expect(appContainer.first()).toBeVisible({ timeout: 60_000 });
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
test("addon epubviewer: nextcloud app route renders", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("epubviewer");
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.loginToStandaloneNextcloud(page);
|
||||
|
||||
const appUrl = new URL("apps/epubviewer/", shared.env.nextcloudBaseUrl).toString();
|
||||
await page.goto(appUrl, { waitUntil: "commit", timeout: 60_000 });
|
||||
|
||||
const appContainer = page.locator(
|
||||
"#app-content, #app-content-vue, #content, #content-vue, .app-epubviewer"
|
||||
);
|
||||
await expect(appContainer.first()).toBeVisible({ timeout: 60_000 });
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
test.use({ ignoreHTTPSErrors: true });
|
||||
|
||||
// event_update_notification is a backend-only Calendar companion app: it emits
|
||||
// notifications when an attendee updates an event and exposes no dedicated user
|
||||
// route. Its only browser-observable surface is the admin "installed apps" list,
|
||||
// so log in as administrator and assert that page renders.
|
||||
test("event_update_notification addon: installed-apps settings page renders", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("event_update_notification");
|
||||
test.setTimeout(120_000);
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.loginToStandaloneNextcloud(page);
|
||||
|
||||
const appsUrl = new URL("settings/apps/installed", shared.env.nextcloudBaseUrl).toString();
|
||||
await page.goto(appsUrl, { waitUntil: "domcontentloaded", timeout: 60_000 });
|
||||
await shared.dismissBlockingNextcloudModals(page, page);
|
||||
|
||||
await expect(
|
||||
page.locator("#app-content, #app-content-vue, #content, #content-vue").first(),
|
||||
"the Nextcloud installed-apps settings page must be visible (event_update_notification has no dedicated user route)",
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
test.use({ ignoreHTTPSErrors: true });
|
||||
|
||||
// external (nextcloud/external) injects admin-configured external links/sites
|
||||
// into the top menu and is configured under the admin "Additional settings"
|
||||
// page; it has no dedicated per-user app route. Log in as administrator and
|
||||
// assert the admin additional-settings page renders as its observable surface.
|
||||
test("external addon: admin additional-settings page renders", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("external");
|
||||
test.setTimeout(120_000);
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.loginToStandaloneNextcloud(page);
|
||||
|
||||
const settingsUrl = new URL("settings/admin/additional", shared.env.nextcloudBaseUrl).toString();
|
||||
await page.goto(settingsUrl, { waitUntil: "domcontentloaded", timeout: 60_000 });
|
||||
await shared.dismissBlockingNextcloudModals(page, page);
|
||||
|
||||
await expect(
|
||||
page.locator("#app-content, #app-content-vue, #content, #content-vue").first(),
|
||||
"the Nextcloud admin additional-settings page must be visible (external sites are admin-configured, no dedicated user route)",
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
test.use({ ignoreHTTPSErrors: true });
|
||||
|
||||
// files_bpm registers an in-Files audio/video player; it has no standalone
|
||||
// route, it only augments the Files app. Log in, open Files, and assert the
|
||||
// Files app content renders with the player app registered.
|
||||
test("files_bpm addon: Files app loads with the media player registered", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("files_bpm");
|
||||
test.setTimeout(120_000);
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.loginToStandaloneNextcloud(page);
|
||||
|
||||
const filesUrl = new URL("apps/files/", shared.env.nextcloudBaseUrl).toString();
|
||||
await page.goto(filesUrl, { waitUntil: "domcontentloaded", timeout: 60_000 });
|
||||
await shared.dismissBlockingNextcloudModals(page, page);
|
||||
|
||||
await expect(
|
||||
page.locator("#app-content, #app-content-vue, #app-navigation-vue").first(),
|
||||
"the Nextcloud Files app content must be visible with the files_bpm media player registered",
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
test.use({ ignoreHTTPSErrors: true });
|
||||
|
||||
// files_linkeditor adds a ".url"/".webloc" link create+edit action to the Files
|
||||
// app; it has no standalone route. Log in, open Files, and assert the Files app
|
||||
// content renders with the link-editor action registered.
|
||||
test("files_linkeditor addon: Files app loads with the link editor registered", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("files_linkeditor");
|
||||
test.setTimeout(120_000);
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.loginToStandaloneNextcloud(page);
|
||||
|
||||
const filesUrl = new URL("apps/files/", shared.env.nextcloudBaseUrl).toString();
|
||||
await page.goto(filesUrl, { waitUntil: "domcontentloaded", timeout: 60_000 });
|
||||
await shared.dismissBlockingNextcloudModals(page, page);
|
||||
|
||||
await expect(
|
||||
page.locator("#app-content, #app-content-vue, #app-navigation-vue").first(),
|
||||
"the Nextcloud Files app content must be visible with the files_linkeditor action registered",
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
test.use({ ignoreHTTPSErrors: true });
|
||||
|
||||
// fileslibreofficeedit registers a LibreOffice "Edit" file action inside the
|
||||
// Files app; it has no standalone route. Log in, open Files, and assert the
|
||||
// Files app content renders with the editor action registered.
|
||||
test("fileslibreofficeedit addon: Files app loads with the LibreOffice editor registered", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("fileslibreofficeedit");
|
||||
test.setTimeout(120_000);
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.loginToStandaloneNextcloud(page);
|
||||
|
||||
const filesUrl = new URL("apps/files/", shared.env.nextcloudBaseUrl).toString();
|
||||
await page.goto(filesUrl, { waitUntil: "domcontentloaded", timeout: 60_000 });
|
||||
await shared.dismissBlockingNextcloudModals(page, page);
|
||||
|
||||
await expect(
|
||||
page.locator("#app-content, #app-content-vue, #app-navigation-vue").first(),
|
||||
"the Nextcloud Files app content must be visible with the fileslibreofficeedit editor action registered",
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
test("addon forms: app route renders the Nextcloud app container", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("forms");
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.loginToStandaloneNextcloud(page);
|
||||
|
||||
const appUrl = new URL("apps/forms/", shared.env.nextcloudBaseUrl).toString();
|
||||
await page.goto(appUrl, { waitUntil: "commit", timeout: 60_000 });
|
||||
|
||||
await expect(
|
||||
page.locator("#app-content, #app-content-vue, #content").first()
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
} finally {
|
||||
await page.close();
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
test("addon gpxpod: app route renders the Nextcloud app container", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("gpxpod");
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.loginToStandaloneNextcloud(page);
|
||||
|
||||
const appUrl = new URL("apps/gpxpod/", shared.env.nextcloudBaseUrl).toString();
|
||||
await page.goto(appUrl, { waitUntil: "commit", timeout: 60_000 });
|
||||
|
||||
await expect(
|
||||
page.locator("#app-content, #app-content-vue, #content").first()
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
} finally {
|
||||
await page.close();
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
test.use({ ignoreHTTPSErrors: true });
|
||||
|
||||
// groupfolders is an admin-managed app: admins define group folders under the
|
||||
// admin settings section, and end users only see the resulting folders inside
|
||||
// the Files app. Its dedicated browser-observable surface is the admin
|
||||
// groupfolders settings page, so log in as administrator and assert it renders.
|
||||
test("groupfolders addon: admin group-folders settings page renders", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("groupfolders");
|
||||
test.setTimeout(120_000);
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.loginToStandaloneNextcloud(page);
|
||||
|
||||
const settingsUrl = new URL("settings/admin/groupfolders", shared.env.nextcloudBaseUrl).toString();
|
||||
await page.goto(settingsUrl, { waitUntil: "domcontentloaded", timeout: 60_000 });
|
||||
await shared.dismissBlockingNextcloudModals(page, page);
|
||||
|
||||
await expect(
|
||||
page.locator("#app-content, #app-content-vue, #content, #content-vue").first(),
|
||||
"the Nextcloud admin group-folders settings page must be visible",
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
test("integration integration_discourse: connects Nextcloud to discourse", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("integration_discourse");
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.loginToStandaloneNextcloud(page);
|
||||
|
||||
await page.goto(
|
||||
new URL("settings/user/connected-accounts", shared.env.nextcloudBaseUrl).toString(),
|
||||
{ waitUntil: "domcontentloaded", timeout: 60_000 }
|
||||
);
|
||||
|
||||
// upstream nextcloud/integration_discourse PersonalSettings.vue:
|
||||
// section id `#discourse_prefs`, connect button `#discourse-oauth`
|
||||
// (rendered only while `showOAuth` is true: a `url` is configured and the
|
||||
// account is not yet connected). onOAuthClick() redirects the page to
|
||||
// `<discourse_url>/user-api-key/new?...` (Discourse User-API-Key grant).
|
||||
const section = page.locator("#discourse_prefs");
|
||||
await section.waitFor({ state: "visible", timeout: 30_000 }).catch(() => {});
|
||||
|
||||
const connect = page.locator("#discourse-oauth, #discourse_prefs button:has-text('Connect to Discourse')");
|
||||
if ((await connect.count()) === 0) {
|
||||
test.skip(true, "integration_discourse: connect control not present (integration not provisioned)");
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
page.waitForEvent("framenavigated", { timeout: 60_000 }).catch(() => {}),
|
||||
connect.first().click(),
|
||||
]);
|
||||
|
||||
// Either we left Nextcloud toward the Discourse User-API-Key authorize
|
||||
// endpoint (proves the configured url + client are wired) or the account is
|
||||
// already linked (connected state) — both are valid "wired" outcomes.
|
||||
await expect
|
||||
.poll(() => page.url(), { timeout: 60_000 })
|
||||
.toMatch(/\/user-api-key\/new|connected-accounts/);
|
||||
|
||||
const reachedDiscourse = /\/user-api-key\/new/.test(page.url());
|
||||
const connectedBack = await page
|
||||
.locator(".discourse-connected, #discourse_prefs >> text=Connected as")
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
expect(
|
||||
reachedDiscourse || connectedBack,
|
||||
"expected the Discourse User-API-Key authorize endpoint or a connected account"
|
||||
).toBeTruthy();
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
// Functional cross-role check: the Nextcloud `integration_github` app must be
|
||||
// wired with a real GitHub OAuth client (client_id/client_secret rendered from
|
||||
// the `api` lookup in meta/addons/integration_github.yml). The strongest
|
||||
// deterministic signal that the wiring is correct is that clicking "Connect to
|
||||
// GitHub with OAuth" hands the browser off to github.com/login/oauth/authorize
|
||||
// with the configured client_id. We do NOT complete consent (that needs live
|
||||
// GitHub credentials and is a separate Tier-2 concern); reaching the GitHub
|
||||
// authorize endpoint already proves the integration URL + client are live.
|
||||
test("integration integration_github: connects Nextcloud to github.com", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("integration_github");
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.loginToStandaloneNextcloud(page);
|
||||
await page.goto(
|
||||
new URL("settings/user/connected-accounts", shared.env.nextcloudBaseUrl).toString(),
|
||||
{ waitUntil: "domcontentloaded", timeout: 60_000 }
|
||||
);
|
||||
|
||||
// The OAuth button (NcButton) only renders when the admin client_id is
|
||||
// configured; it navigates the top frame via window.location.replace() to
|
||||
// github.com/login/oauth/authorize. Match it by its OAuth-specific label so
|
||||
// we never click the "personal token" button instead.
|
||||
const connect = page.getByRole("button", { name: /connect to github with oauth/i });
|
||||
|
||||
if ((await connect.count()) === 0) {
|
||||
test.skip(true, "integration_github: connect control not present (integration not provisioned)");
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
page.waitForEvent("framenavigated", { timeout: 60_000 }).catch(() => {}),
|
||||
connect.first().click()
|
||||
]);
|
||||
|
||||
await expect
|
||||
.poll(() => page.url(), { timeout: 60_000 })
|
||||
.toMatch(/github\.com\/login\/oauth\/authorize|connected-accounts/);
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
// Functional cross-role integration check: drive the real OAuth connect flow
|
||||
// from Nextcloud to the GitLab partner instance.
|
||||
// nextcloud/integration_gitlab renders a "Connect to GitLab using OAuth" button
|
||||
// that redirects the browser to
|
||||
// `<gitlab-instance>/oauth/authorize?client_id=…&response_type=code&scope=…`.
|
||||
// Reaching that authorize endpoint proves the partner URL + OAuth client are
|
||||
// wired. Full client provisioning is a separate Tier-2 feature, so skip cleanly
|
||||
// when the connect control is absent.
|
||||
test("integration integration_gitlab: connects Nextcloud to gitlab", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("integration_gitlab");
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.loginToStandaloneNextcloud(page);
|
||||
await page.goto(
|
||||
new URL("settings/user/connected-accounts", shared.env.nextcloudBaseUrl).toString(),
|
||||
{ waitUntil: "domcontentloaded", timeout: 60_000 }
|
||||
);
|
||||
|
||||
const connect = page
|
||||
.getByRole("button", { name: /connect to gitlab/i })
|
||||
.or(page.getByRole("link", { name: /connect to gitlab/i }));
|
||||
|
||||
if ((await connect.count()) === 0) {
|
||||
test.skip(true, "integration_gitlab: connect control not present (integration not provisioned)");
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
page.waitForEvent("framenavigated", { timeout: 60_000 }).catch(() => {}),
|
||||
connect.first().click(),
|
||||
]);
|
||||
|
||||
await expect
|
||||
.poll(() => page.url(), { timeout: 60_000 })
|
||||
.toMatch(/\/oauth\/authorize\?|connected-accounts/i);
|
||||
|
||||
const reachedAuthorize = /\/oauth\/authorize\?/i.test(page.url());
|
||||
if (reachedAuthorize) {
|
||||
const authorizeUrl = new URL(page.url());
|
||||
const nextcloudHost = new URL(shared.env.nextcloudBaseUrl).host;
|
||||
expect(
|
||||
authorizeUrl.host,
|
||||
"GitLab OAuth authorize must be served by the partner instance, not Nextcloud"
|
||||
).not.toBe(nextcloudHost);
|
||||
expect(authorizeUrl.searchParams.get("client_id")).toBeTruthy();
|
||||
expect(authorizeUrl.searchParams.get("response_type")).toBe("code");
|
||||
} else {
|
||||
test.skip(
|
||||
true,
|
||||
"integration_gitlab: connect did not redirect to GitLab authorize (OAuth client not provisioned)"
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
// Functional cross-role check: the Nextcloud `integration_google` app must be
|
||||
// wired with a real Google OAuth client (client_id/client_secret rendered from
|
||||
// the `api` lookup in meta/addons/integration_google.yml). Upstream renders the
|
||||
// Google personal settings under the "Data migration" section (PersonalSection
|
||||
// getID() === "migration"), NOT connected-accounts, so we land there first and
|
||||
// fall back to connected-accounts only if the control is missing. Clicking
|
||||
// "Sign in with Google" runs window.location.replace() to
|
||||
// accounts.google.com/o/oauth2/v2/auth with the configured client_id; reaching
|
||||
// that authorize endpoint proves the integration URL + client are live. Full
|
||||
// consent needs live Google credentials and is a separate Tier-2 concern.
|
||||
test("integration integration_google: connects Nextcloud to Google", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("integration_google");
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
// Match the upstream button text ("Sign in with Google") on any of the
|
||||
// settings pages the app may render under across NC versions.
|
||||
const connectFor = (target) =>
|
||||
target.getByRole("button", { name: /sign in with google/i });
|
||||
|
||||
try {
|
||||
await shared.loginToStandaloneNextcloud(page);
|
||||
|
||||
await page.goto(
|
||||
new URL("settings/user/migration", shared.env.nextcloudBaseUrl).toString(),
|
||||
{ waitUntil: "domcontentloaded", timeout: 60_000 }
|
||||
);
|
||||
|
||||
let connect = connectFor(page);
|
||||
if ((await connect.count()) === 0) {
|
||||
await page.goto(
|
||||
new URL("settings/user/connected-accounts", shared.env.nextcloudBaseUrl).toString(),
|
||||
{ waitUntil: "domcontentloaded", timeout: 60_000 }
|
||||
);
|
||||
connect = connectFor(page);
|
||||
}
|
||||
|
||||
if ((await connect.count()) === 0) {
|
||||
test.skip(true, "integration_google: connect control not present (integration not provisioned)");
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
page.waitForEvent("framenavigated", { timeout: 60_000 }).catch(() => {}),
|
||||
connect.first().click()
|
||||
]);
|
||||
|
||||
await expect
|
||||
.poll(() => page.url(), { timeout: 60_000 })
|
||||
.toMatch(/accounts\.google\.com\/o\/oauth2|Successfully connected to Google/);
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
// Functional cross-role check: the Nextcloud `integration_jira` app must be
|
||||
// wired with a real Atlassian OAuth client (client_id/client_secret rendered
|
||||
// from the `api` lookup in meta/addons/integration_jira.yml). Upstream renders
|
||||
// the Jira personal settings under the connected-accounts section. The
|
||||
// "Connect to Jira Cloud" NcButton only appears when the admin client_id is
|
||||
// configured; clicking it runs window.location.replace() to
|
||||
// auth.atlassian.com/authorize with the configured client_id. Reaching that
|
||||
// Atlassian authorize endpoint proves the integration URL + client are live.
|
||||
// Full consent needs live Atlassian credentials (separate Tier-2 concern).
|
||||
test("integration integration_jira: connects Nextcloud to Atlassian Jira", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("integration_jira");
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.loginToStandaloneNextcloud(page);
|
||||
await page.goto(
|
||||
new URL("settings/user/connected-accounts", shared.env.nextcloudBaseUrl).toString(),
|
||||
{ waitUntil: "domcontentloaded", timeout: 60_000 }
|
||||
);
|
||||
|
||||
const connect = page.getByRole("button", { name: /connect to jira cloud/i });
|
||||
|
||||
if ((await connect.count()) === 0) {
|
||||
test.skip(true, "integration_jira: connect control not present (integration not provisioned)");
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
page.waitForEvent("framenavigated", { timeout: 60_000 }).catch(() => {}),
|
||||
connect.first().click()
|
||||
]);
|
||||
|
||||
await expect
|
||||
.poll(() => page.url(), { timeout: 60_000 })
|
||||
.toMatch(/auth\.atlassian\.com\/authorize|connected-accounts/);
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
// Functional cross-role integration check: drive the real OAuth connect flow
|
||||
// from Nextcloud's connected-accounts UI to the Mastodon partner instance.
|
||||
// nextcloud/integration_mastodon renders a "Connect to Mastodon" button that,
|
||||
// once the instance URL is set, redirects the browser to
|
||||
// `<mastodon-instance>/oauth/authorize?client_id=…&response_type=code`. Reaching
|
||||
// that authorize endpoint proves the partner URL + OAuth client are wired.
|
||||
// Full OAuth client provisioning between Nextcloud and Mastodon is a separate
|
||||
// Tier-2 feature, so skip cleanly when the connect control is absent.
|
||||
test("integration integration_mastodon: connects Nextcloud to mastodon", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("integration_mastodon");
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.loginToStandaloneNextcloud(page);
|
||||
await page.goto(
|
||||
new URL("settings/user/connected-accounts", shared.env.nextcloudBaseUrl).toString(),
|
||||
{ waitUntil: "domcontentloaded", timeout: 60_000 }
|
||||
);
|
||||
|
||||
const connect = page
|
||||
.getByRole("button", { name: /connect to mastodon/i })
|
||||
.or(page.getByRole("link", { name: /connect to mastodon/i }));
|
||||
|
||||
if ((await connect.count()) === 0) {
|
||||
test.skip(true, "integration_mastodon: connect control not present (integration not provisioned)");
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
page.waitForEvent("framenavigated", { timeout: 60_000 }).catch(() => {}),
|
||||
connect.first().click(),
|
||||
]);
|
||||
|
||||
// Mastodon OAuth authorize endpoint OR an already-connected NC state.
|
||||
await expect
|
||||
.poll(() => page.url(), { timeout: 60_000 })
|
||||
.toMatch(/\/oauth\/authorize\?|connected-accounts/i);
|
||||
|
||||
// If the OAuth client is fully provisioned the URL leaves Nextcloud toward
|
||||
// `<mastodon>/oauth/authorize`. Assert that handoff happened rather than the
|
||||
// app bouncing back with an error inside the NC settings page.
|
||||
const reachedAuthorize = /\/oauth\/authorize\?/i.test(page.url());
|
||||
if (reachedAuthorize) {
|
||||
const authorizeUrl = new URL(page.url());
|
||||
const nextcloudHost = new URL(shared.env.nextcloudBaseUrl).host;
|
||||
expect(
|
||||
authorizeUrl.host,
|
||||
"Mastodon OAuth authorize must be served by the partner instance, not Nextcloud"
|
||||
).not.toBe(nextcloudHost);
|
||||
expect(authorizeUrl.searchParams.get("client_id")).toBeTruthy();
|
||||
expect(authorizeUrl.searchParams.get("response_type")).toBe("code");
|
||||
} else {
|
||||
test.skip(
|
||||
true,
|
||||
"integration_mastodon: connect did not redirect to Mastodon authorize (OAuth client not provisioned)"
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
test("integration integration_matrix: connects Nextcloud to matrix", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("integration_matrix");
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.loginToStandaloneNextcloud(page);
|
||||
|
||||
await page.goto(
|
||||
new URL("settings/user/connected-accounts", shared.env.nextcloudBaseUrl).toString(),
|
||||
{ waitUntil: "domcontentloaded", timeout: 60_000 }
|
||||
);
|
||||
|
||||
// upstream nextcloud/integration_matrix PersonalSettings.vue:
|
||||
// section id `#matrix_prefs`. The "Connect with OAuth" button renders only
|
||||
// while `state.oauth_possible` (admin provisioned an OAuth client + server
|
||||
// url). connectWithOauth() POSTs to `/apps/integration_matrix/oauth-start`,
|
||||
// which returns the Matrix server's discovered `authorization_endpoint`,
|
||||
// then the page does window.location.replace(authorization_url). Without
|
||||
// OAuth provisioning the section only offers the manual access-token
|
||||
// connect (no authorize redirect) — skip in that case.
|
||||
const section = page.locator("#matrix_prefs");
|
||||
await section.waitFor({ state: "visible", timeout: 30_000 }).catch(() => {});
|
||||
|
||||
const connect = page.getByRole("button", { name: /connect with oauth/i });
|
||||
if ((await connect.count()) === 0) {
|
||||
test.skip(true, "integration_matrix: OAuth connect control not present (integration not provisioned)");
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
page.waitForEvent("framenavigated", { timeout: 60_000 }).catch(() => {}),
|
||||
connect.first().click(),
|
||||
]);
|
||||
|
||||
// Assert the flow reached the Matrix server's OAuth authorize endpoint (the
|
||||
// discovered authorization_endpoint always carries an `authorize` path +
|
||||
// the configured client_id) — strongest signal the URL + client are wired —
|
||||
// or that the account shows connected back in Nextcloud.
|
||||
await expect
|
||||
.poll(() => page.url(), { timeout: 60_000 })
|
||||
.toMatch(/authorize|connected-accounts/i);
|
||||
|
||||
const reachedAuthorize = /authorize/i.test(page.url()) && !/connected-accounts/.test(page.url());
|
||||
const connectedBack = await page
|
||||
.locator(".matrix-connected, #matrix_prefs >> text=Connected as")
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
expect(
|
||||
reachedAuthorize || connectedBack,
|
||||
"expected the Matrix OAuth authorize endpoint or a connected account"
|
||||
).toBeTruthy();
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
test("integration integration_mattermost: connects Nextcloud to mattermost", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("integration_mattermost");
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.loginToStandaloneNextcloud(page);
|
||||
|
||||
await page.goto(
|
||||
new URL("settings/user/connected-accounts", shared.env.nextcloudBaseUrl).toString(),
|
||||
{ waitUntil: "domcontentloaded", timeout: 60_000 }
|
||||
);
|
||||
|
||||
// upstream nextcloud/integration_mattermost PersonalSettings.vue:
|
||||
// section id `#mattermost_prefs`, connect button `#mattermost-connect`.
|
||||
// onConnectClick() only performs an OAuth handoff to
|
||||
// `<mattermost_url>/oauth/authorize` when `showOAuth` is true (the admin
|
||||
// configured client_id + client_secret for the same instance url). Without
|
||||
// that Tier-2 OAuth provisioning the same button connects via a personal
|
||||
// token / login+password and never leaves Nextcloud. The OAuth-only block
|
||||
// `#mattermost-connect-block .oauth` is the deterministic marker of a wired
|
||||
// OAuth client; absent it, skip rather than asserting a non-OAuth connect.
|
||||
const section = page.locator("#mattermost_prefs");
|
||||
await section.waitFor({ state: "visible", timeout: 30_000 }).catch(() => {});
|
||||
|
||||
const connect = page.locator("#mattermost-connect");
|
||||
if ((await connect.count()) === 0) {
|
||||
test.skip(true, "integration_mattermost: connect control not present (integration not provisioned)");
|
||||
return;
|
||||
}
|
||||
|
||||
// The OAuth-specific copy ("...with OAuth") only renders when
|
||||
// client_id/secret are set (showOAuth). Without it the same button performs
|
||||
// a personal-token / login+password connect that never leaves Nextcloud, so
|
||||
// there is no partner authorize redirect to drive — skip instead.
|
||||
const oauthActive = await page
|
||||
.locator("#mattermost_prefs >> text=/OAuth/i")
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
if (!oauthActive) {
|
||||
test.skip(
|
||||
true,
|
||||
"integration_mattermost: OAuth client not provisioned (token/password connect only, no authorize redirect)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
page.waitForEvent("framenavigated", { timeout: 60_000 }).catch(() => {}),
|
||||
connect.first().click(),
|
||||
]);
|
||||
|
||||
// Assert the flow reached the Mattermost OAuth authorize endpoint
|
||||
// (`<mattermost_url>/oauth/authorize`) — strongest signal the URL + client
|
||||
// are wired — or that the account already shows connected back in Nextcloud.
|
||||
await expect
|
||||
.poll(() => page.url(), { timeout: 60_000 })
|
||||
.toMatch(/\/oauth\/authorize|connected-accounts/);
|
||||
|
||||
const reachedAuthorize = /\/oauth\/authorize/.test(page.url());
|
||||
const connectedBack = await page
|
||||
.locator(".mattermost-connected, #mattermost_prefs >> text=Connected as")
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
expect(
|
||||
reachedAuthorize || connectedBack,
|
||||
"expected the Mattermost /oauth/authorize endpoint or a connected account"
|
||||
).toBeTruthy();
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
test("integration integration_moodle: connects Nextcloud to moodle", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("integration_moodle");
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.loginToStandaloneNextcloud(page);
|
||||
|
||||
await page.goto(
|
||||
new URL("settings/user/connected-accounts", shared.env.nextcloudBaseUrl).toString(),
|
||||
{ waitUntil: "domcontentloaded", timeout: 60_000 }
|
||||
);
|
||||
|
||||
// upstream nextcloud/integration_moodle PersonalSettings.vue:
|
||||
// section id `#moodle_prefs`, configured-endpoint field `#moodle-url`,
|
||||
// "Connect to Moodle" button. Moodle has NO user OAuth consent: onValidate()
|
||||
// POSTs login+password to `/apps/integration_moodle/get-token` to mint a
|
||||
// Moodle web-service token (no redirect to a partner authorize endpoint).
|
||||
// The deterministic "integration is wired" signal is therefore the
|
||||
// pre-filled Moodle service URL the addon configured (mirrors the openai
|
||||
// case). If the section / Connect control is absent, the integration is not
|
||||
// provisioned in this deployment — skip cleanly.
|
||||
const section = page.locator("#moodle_prefs");
|
||||
await section.waitFor({ state: "visible", timeout: 30_000 }).catch(() => {});
|
||||
|
||||
const connect = page.locator("#moodle_prefs button:has-text('Connect to Moodle')");
|
||||
if ((await connect.count()) === 0) {
|
||||
test.skip(true, "integration_moodle: connect control not present (integration not provisioned)");
|
||||
return;
|
||||
}
|
||||
|
||||
// Assert the addon-configured Moodle endpoint URL is wired into the connect
|
||||
// form (proves the cross-role URL was provisioned). Moodle does not OAuth-
|
||||
// redirect, so the configured endpoint is the strongest deterministic
|
||||
// signal short of full credentialed token minting.
|
||||
const urlField = page.locator("#moodle-url");
|
||||
await expect(urlField).toBeVisible({ timeout: 30_000 });
|
||||
const configuredUrl = await urlField.inputValue().catch(() => "");
|
||||
expect(
|
||||
/^https?:\/\/.+/.test(configuredUrl),
|
||||
"expected the Moodle service URL to be configured (pointing at the partner instance)"
|
||||
).toBeTruthy();
|
||||
|
||||
// Drive the connect attempt: clicking with no credentials must not leave
|
||||
// Nextcloud (no partner authorize endpoint exists), confirming the flow is
|
||||
// the get-token grant rather than an OAuth handoff.
|
||||
await connect.first().click().catch(() => {});
|
||||
await page.waitForTimeout(2_000);
|
||||
await expect.poll(() => page.url(), { timeout: 30_000 }).toMatch(/connected-accounts/);
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
// Functional cross-role integration check for the OpenAI-compatible AI backend
|
||||
// partner (openwebui or flowise — whichever is deployed).
|
||||
// Reality of upstream nextcloud/integration_openai: the connection is NOT a
|
||||
// user OAuth consent flow. It is an admin-level "Service URL" + API key form
|
||||
// (AdminSettings.vue: field label "Service URL", placeholder
|
||||
// "Example: http://localhost:8080/v1"). So the strongest deterministic,
|
||||
// partner-specific signal is that the integration's ADMIN settings show a
|
||||
// configured (non-empty) endpoint URL pointing at the AI backend — which is
|
||||
// exactly what the cross-role provisioning wires (configkey "url" =
|
||||
// web-app-openwebui / web-app-flowise base URL). Skip cleanly when the Service
|
||||
// URL field is absent (integration not provisioned in this deployment).
|
||||
test("integration integration_openai: connects Nextcloud to openwebui/flowise", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("integration_openai");
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.loginToStandaloneNextcloud(page);
|
||||
|
||||
// The OpenAI/LocalAI connection is configured admin-side; the personal
|
||||
// connected-accounts page only exposes per-user prefs. Go straight to the
|
||||
// admin AI settings where the Service URL is wired.
|
||||
const adminAiUrl = new URL("settings/admin/ai", shared.env.nextcloudBaseUrl).toString();
|
||||
await page.goto(adminAiUrl, { waitUntil: "domcontentloaded", timeout: 60_000 });
|
||||
|
||||
// Locate THIS integration's Service URL control. Match by accessible name
|
||||
// (label "Service URL") and fall back to the OpenAI/LocalAI section's URL
|
||||
// input. If absent, the integration is not provisioned here.
|
||||
const serviceUrlField = page
|
||||
.getByRole("textbox", { name: /service url/i })
|
||||
.or(page.locator("#openai-api #openai-url input, .openai input[type='text'], input[id*='openai'][type='text']"));
|
||||
|
||||
const present = await serviceUrlField
|
||||
.first()
|
||||
.waitFor({ state: "visible", timeout: 30_000 })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
if (!present) {
|
||||
test.skip(
|
||||
true,
|
||||
"integration_openai: Service URL control not present (integration not provisioned)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// No OAuth redirect for this partner: assert the admin settings show a
|
||||
// configured endpoint URL (non-empty, http(s)) — proving the AI backend URL
|
||||
// is wired into the integration rather than left at the OpenAI default.
|
||||
const configuredUrl = (await serviceUrlField.first().inputValue()).trim();
|
||||
expect(
|
||||
configuredUrl,
|
||||
"integration_openai admin Service URL must hold the configured openwebui/flowise endpoint"
|
||||
).toMatch(/^https?:\/\/.+/i);
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
// Functional cross-role integration check: drive the real OAuth connect flow
|
||||
// from Nextcloud to the OpenProject partner instance.
|
||||
// nextcloud/integration_openproject renders a "Connect to OpenProject" button
|
||||
// that fetches the server-side OAuth URL (`/op-oauth-url`) and then
|
||||
// `window.location.replace(...)` to `<openproject-instance>/oauth/authorize`.
|
||||
// Reaching that authorize endpoint proves the partner URL + OAuth client are
|
||||
// wired. Full client provisioning is a separate Tier-2 feature, so skip cleanly
|
||||
// when the connect control is absent.
|
||||
test("integration integration_openproject: connects Nextcloud to openproject", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("integration_openproject");
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.loginToStandaloneNextcloud(page);
|
||||
await page.goto(
|
||||
new URL("settings/user/connected-accounts", shared.env.nextcloudBaseUrl).toString(),
|
||||
{ waitUntil: "domcontentloaded", timeout: 60_000 }
|
||||
);
|
||||
|
||||
const connect = page
|
||||
.getByRole("button", { name: /connect to openproject/i })
|
||||
.or(page.getByRole("link", { name: /connect to openproject/i }));
|
||||
|
||||
if ((await connect.count()) === 0) {
|
||||
test.skip(true, "integration_openproject: connect control not present (integration not provisioned)");
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
page.waitForEvent("framenavigated", { timeout: 60_000 }).catch(() => {}),
|
||||
connect.first().click(),
|
||||
]);
|
||||
|
||||
await expect
|
||||
.poll(() => page.url(), { timeout: 60_000 })
|
||||
.toMatch(/\/oauth\/authorize\?|connected-accounts/i);
|
||||
|
||||
const reachedAuthorize = /\/oauth\/authorize\?/i.test(page.url());
|
||||
if (reachedAuthorize) {
|
||||
const authorizeUrl = new URL(page.url());
|
||||
const nextcloudHost = new URL(shared.env.nextcloudBaseUrl).host;
|
||||
expect(
|
||||
authorizeUrl.host,
|
||||
"OpenProject OAuth authorize must be served by the partner instance, not Nextcloud"
|
||||
).not.toBe(nextcloudHost);
|
||||
expect(authorizeUrl.searchParams.get("client_id")).toBeTruthy();
|
||||
expect(authorizeUrl.searchParams.get("response_type")).toBe("code");
|
||||
} else {
|
||||
test.skip(
|
||||
true,
|
||||
"integration_openproject: connect did not redirect to OpenProject authorize (OAuth client not provisioned)"
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
// Functional cross-role integration check for the PeerTube partner.
|
||||
// Reality of upstream nextcloud/integration_peertube: its personal settings
|
||||
// expose only "search" and "link preview" toggles — there is NO user OAuth
|
||||
// connect handoff (unlike mastodon/gitlab/github). So the strongest
|
||||
// deterministic, partner-specific signal available here is that the
|
||||
// integration's own settings section is wired into connected-accounts and the
|
||||
// configured PeerTube instance URL is reachable from the personal settings.
|
||||
// If a connect/login control is ever introduced upstream we detect and drive
|
||||
// it; otherwise we assert the PeerTube settings surface is present, and skip
|
||||
// cleanly when the integration is not provisioned in this deployment.
|
||||
test("integration integration_peertube: connects Nextcloud to peertube", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("integration_peertube");
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.loginToStandaloneNextcloud(page);
|
||||
await page.goto(
|
||||
new URL("settings/user/connected-accounts", shared.env.nextcloudBaseUrl).toString(),
|
||||
{ waitUntil: "domcontentloaded", timeout: 60_000 }
|
||||
);
|
||||
|
||||
const connect = page
|
||||
.getByRole("button", { name: /connect to peertube|sign in with peertube/i })
|
||||
.or(page.getByRole("link", { name: /connect to peertube|sign in with peertube/i }));
|
||||
|
||||
if ((await connect.count()) > 0) {
|
||||
await Promise.all([
|
||||
page.waitForEvent("framenavigated", { timeout: 60_000 }).catch(() => {}),
|
||||
connect.first().click(),
|
||||
]);
|
||||
await expect
|
||||
.poll(() => page.url(), { timeout: 60_000 })
|
||||
.toMatch(/\/oauth\/authorize\?|\/authorize\?|connected-accounts/i);
|
||||
return;
|
||||
}
|
||||
|
||||
// No OAuth handoff upstream: assert the PeerTube integration section itself
|
||||
// rendered into the connected-accounts settings. Absence => not provisioned.
|
||||
const peertubeSection = page
|
||||
.locator("#peertube_prefs, .peertube, [id*='peertube'], [class*='peertube']")
|
||||
.or(page.getByText(/peertube/i));
|
||||
|
||||
if ((await peertubeSection.count()) === 0) {
|
||||
test.skip(true, "integration_peertube: settings section not present (integration not provisioned)");
|
||||
return;
|
||||
}
|
||||
|
||||
await expect(peertubeSection.first()).toBeVisible({ timeout: 30_000 });
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
test("integration integration_suitecrm: connects Nextcloud to suitecrm", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("integration_suitecrm");
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.loginToStandaloneNextcloud(page);
|
||||
|
||||
await page.goto(
|
||||
new URL("settings/user/connected-accounts", shared.env.nextcloudBaseUrl).toString(),
|
||||
{ waitUntil: "domcontentloaded", timeout: 60_000 }
|
||||
);
|
||||
|
||||
// upstream julien-nc/integration_suitecrm PersonalSettings.vue:
|
||||
// section id `#suitecrm_prefs`. The connect block `#suitecrm-content`
|
||||
// (with field `#suitecrm-url` and button `#suitecrm-oauth`) renders only
|
||||
// while `oAuthConfigured` (admin provisioned oauth_instance_url + client_id
|
||||
// + client_secret); otherwise a "No SuiteCRM OAuth app configured" hint
|
||||
// shows instead. onConnect() runs the OAuth2 *password grant* server-side
|
||||
// (POST `/apps/integration_suitecrm/oauth-connect`) — there is no browser
|
||||
// redirect to a SuiteCRM authorize endpoint. The deterministic "wired"
|
||||
// signal is the provisioned SuiteCRM endpoint URL behind the OAuth-app gate.
|
||||
const section = page.locator("#suitecrm_prefs");
|
||||
await section.waitFor({ state: "visible", timeout: 30_000 }).catch(() => {});
|
||||
|
||||
const connect = page.locator("#suitecrm-oauth, #suitecrm-content button:has-text('Connect to SuiteCRM')");
|
||||
if ((await connect.count()) === 0) {
|
||||
test.skip(
|
||||
true,
|
||||
"integration_suitecrm: connect control not present (SuiteCRM OAuth app not provisioned)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Assert the addon-configured SuiteCRM endpoint URL is wired into the
|
||||
// connect form (proves the cross-role url + OAuth client were provisioned).
|
||||
const urlField = page.locator("#suitecrm-url");
|
||||
await expect(urlField).toBeVisible({ timeout: 30_000 });
|
||||
const configuredUrl = await urlField.inputValue().catch(() => "");
|
||||
expect(
|
||||
/^https?:\/\/.+/.test(configuredUrl),
|
||||
"expected the SuiteCRM instance URL to be configured (pointing at the partner instance)"
|
||||
).toBeTruthy();
|
||||
|
||||
// Drive the connect attempt; the password-grant runs server-side, so the
|
||||
// page must stay on connected-accounts rather than hand off to a partner
|
||||
// authorize endpoint — confirming the grant shape this integration uses.
|
||||
await connect.first().click().catch(() => {});
|
||||
await page.waitForTimeout(2_000);
|
||||
await expect.poll(() => page.url(), { timeout: 30_000 }).toMatch(/connected-accounts/);
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
test("integration integration_zammad: connects Nextcloud to zammad", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("integration_zammad");
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.loginToStandaloneNextcloud(page);
|
||||
|
||||
await page.goto(
|
||||
new URL("settings/user/connected-accounts", shared.env.nextcloudBaseUrl).toString(),
|
||||
{ waitUntil: "domcontentloaded", timeout: 60_000 }
|
||||
);
|
||||
|
||||
// upstream nextcloud/integration_zammad PersonalSettings.vue:
|
||||
// section id `#zammad_prefs`. The OAuth button `#zammad-oauth` ("Connect to
|
||||
// Zammad") renders only while `showOAuth` (the addon-configured url equals
|
||||
// the admin oauth_instance_url AND a client_id is set). onOAuthClick()
|
||||
// redirects the page to `<zammad_url>/oauth/authorize?client_id=...`.
|
||||
// Without that OAuth provisioning the section only exposes a personal-token
|
||||
// field (no connect button) — skip cleanly.
|
||||
const section = page.locator("#zammad_prefs");
|
||||
await section.waitFor({ state: "visible", timeout: 30_000 }).catch(() => {});
|
||||
|
||||
const connect = page.locator("#zammad-oauth");
|
||||
if ((await connect.count()) === 0) {
|
||||
test.skip(true, "integration_zammad: OAuth connect control not present (integration not provisioned)");
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
page.waitForEvent("framenavigated", { timeout: 60_000 }).catch(() => {}),
|
||||
connect.first().click(),
|
||||
]);
|
||||
|
||||
// Assert the flow reached the Zammad OAuth authorize endpoint
|
||||
// (`<zammad_url>/oauth/authorize`) — strongest signal the URL + client are
|
||||
// wired — or that the account shows connected back in Nextcloud.
|
||||
await expect
|
||||
.poll(() => page.url(), { timeout: 60_000 })
|
||||
.toMatch(/\/oauth\/authorize|connected-accounts/);
|
||||
|
||||
const reachedAuthorize = /\/oauth\/authorize/.test(page.url());
|
||||
const connectedBack = await page
|
||||
.locator(".zammad-connected, #zammad_prefs >> text=Connected as")
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
expect(
|
||||
reachedAuthorize || connectedBack,
|
||||
"expected the Zammad /oauth/authorize endpoint or a connected account"
|
||||
).toBeTruthy();
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
test("addon mail: app route renders the Nextcloud app container", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("mail");
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.loginToStandaloneNextcloud(page);
|
||||
|
||||
const appUrl = new URL("apps/mail/", shared.env.nextcloudBaseUrl).toString();
|
||||
await page.goto(appUrl, { waitUntil: "commit", timeout: 60_000 });
|
||||
|
||||
await expect(
|
||||
page.locator("#app-content, #app-content-vue, #content").first()
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
} finally {
|
||||
await page.close();
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
test.use({ ignoreHTTPSErrors: true });
|
||||
|
||||
// metadata extracts file metadata (EXIF/GPS etc.) shown in the Files sidebar;
|
||||
// it has no standalone route. Log in, open Files, and assert the Files app
|
||||
// content renders with the metadata provider registered.
|
||||
test("metadata addon: Files app loads with the metadata provider registered", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("metadata");
|
||||
test.setTimeout(120_000);
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.loginToStandaloneNextcloud(page);
|
||||
|
||||
const filesUrl = new URL("apps/files/", shared.env.nextcloudBaseUrl).toString();
|
||||
await page.goto(filesUrl, { waitUntil: "domcontentloaded", timeout: 60_000 });
|
||||
await shared.dismissBlockingNextcloudModals(page, page);
|
||||
|
||||
await expect(
|
||||
page.locator("#app-content, #app-content-vue, #app-navigation-vue").first(),
|
||||
"the Nextcloud Files app content must be visible with the metadata provider registered",
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
test("addon news: app route renders the Nextcloud app container", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("news");
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.loginToStandaloneNextcloud(page);
|
||||
|
||||
const appUrl = new URL("apps/news/", shared.env.nextcloudBaseUrl).toString();
|
||||
await page.goto(appUrl, { waitUntil: "commit", timeout: 60_000 });
|
||||
|
||||
await expect(
|
||||
page.locator("#app-content, #app-content-vue, #content").first()
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
} finally {
|
||||
await page.close();
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const { skipUnlessServiceEnabled } = require("../service-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
// `oidc_login` (pulsejet/nextcloud-oidc-login) is the OIDC auth-path plugin
|
||||
// selected when sso_oidc_plugin == "oidc_login" (the OIDC+LDAP flavor, which
|
||||
// auto-redirects /login straight to Keycloak). It has no distinct in-app page;
|
||||
// its only browser-reachable surface is the login journey. The shared
|
||||
// `loginToStandaloneNextcloud` flow already handles the auto_redirect /
|
||||
// alt-login button shapes for this flavor, so we reuse it rather than
|
||||
// reimplementing OIDC mechanics, and assert an authenticated shell results.
|
||||
test("addon oidc_login: nextcloud OIDC login surface authenticates", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("oidc_login");
|
||||
skipUnlessServiceEnabled("sso");
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.loginToStandaloneNextcloud(page);
|
||||
|
||||
const shellState = await shared.waitForVisibleCandidate(
|
||||
page,
|
||||
shared.getNextcloudShellCandidates(page),
|
||||
60_000,
|
||||
"Timed out waiting for an authenticated Nextcloud shell after the oidc_login OIDC login flow",
|
||||
);
|
||||
await expect(shellState.locator).toBeVisible();
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const { skipUnlessServiceEnabled } = require("../service-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
test.use({ ignoreHTTPSErrors: true });
|
||||
|
||||
test("onlyoffice addon: Files app loads with the ONLYOFFICE editor registered", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("onlyoffice");
|
||||
skipUnlessServiceEnabled("onlyoffice");
|
||||
test.setTimeout(120_000);
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.loginToStandaloneNextcloud(page);
|
||||
|
||||
const filesUrl = new URL("apps/files/", shared.env.nextcloudBaseUrl).toString();
|
||||
await page.goto(filesUrl, { waitUntil: "domcontentloaded", timeout: 60_000 });
|
||||
await shared.dismissBlockingNextcloudModals(page, page);
|
||||
|
||||
await expect(
|
||||
page.locator("#app-content, #app-content-vue, #app-navigation-vue").first(),
|
||||
"the Nextcloud Files app content must be visible with the ONLYOFFICE editor app registered",
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
test("addon phonetrack: app route renders the Nextcloud app container", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("phonetrack");
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.loginToStandaloneNextcloud(page);
|
||||
|
||||
const appUrl = new URL("apps/phonetrack/", shared.env.nextcloudBaseUrl).toString();
|
||||
await page.goto(appUrl, { waitUntil: "commit", timeout: 60_000 });
|
||||
|
||||
await expect(
|
||||
page.locator("#app-content, #app-content-vue, #content").first()
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
} finally {
|
||||
await page.close();
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
test("addon polls: app route renders the Nextcloud app container", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("polls");
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.loginToStandaloneNextcloud(page);
|
||||
|
||||
const appUrl = new URL("apps/polls/", shared.env.nextcloudBaseUrl).toString();
|
||||
await page.goto(appUrl, { waitUntil: "commit", timeout: 60_000 });
|
||||
|
||||
await expect(
|
||||
page.locator("#app-content, #app-content-vue, #content").first()
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
} finally {
|
||||
await page.close();
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
test.use({ ignoreHTTPSErrors: true });
|
||||
|
||||
// quota_warning emails/notifies users approaching their storage quota and is
|
||||
// configured under its admin settings section; it has no per-user app route.
|
||||
// Log in as administrator and assert the admin quota-warning settings page
|
||||
// renders as its observable surface.
|
||||
test("quota_warning addon: admin quota-warning settings page renders", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("quota_warning");
|
||||
test.setTimeout(120_000);
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.loginToStandaloneNextcloud(page);
|
||||
|
||||
const settingsUrl = new URL("settings/admin/quota_warning", shared.env.nextcloudBaseUrl).toString();
|
||||
await page.goto(settingsUrl, { waitUntil: "domcontentloaded", timeout: 60_000 });
|
||||
await shared.dismissBlockingNextcloudModals(page, page);
|
||||
|
||||
await expect(
|
||||
page.locator("#app-content, #app-content-vue, #content, #content-vue").first(),
|
||||
"the Nextcloud admin quota-warning settings page must be visible (quota_warning has no per-user route)",
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
test.use({ ignoreHTTPSErrors: true });
|
||||
|
||||
// recognize runs background ML tagging of photos/audio/video and is configured
|
||||
// under its admin settings section; it has no dedicated per-user route (its
|
||||
// results surface inside Photos/Files tags). Log in as administrator and assert
|
||||
// the admin recognize settings page renders as its observable surface.
|
||||
test("recognize addon: admin recognize settings page renders", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("recognize");
|
||||
test.setTimeout(120_000);
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.loginToStandaloneNextcloud(page);
|
||||
|
||||
const settingsUrl = new URL("settings/admin/recognize", shared.env.nextcloudBaseUrl).toString();
|
||||
await page.goto(settingsUrl, { waitUntil: "domcontentloaded", timeout: 60_000 });
|
||||
await shared.dismissBlockingNextcloudModals(page, page);
|
||||
|
||||
await expect(
|
||||
page.locator("#app-content, #app-content-vue, #content, #content-vue").first(),
|
||||
"the Nextcloud admin recognize settings page must be visible (recognize is a background ML app, no per-user route)",
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const { skipUnlessServiceEnabled } = require("../service-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
test.use({ ignoreHTTPSErrors: true });
|
||||
|
||||
test("richdocuments addon: Files app loads with the Collabora editor registered", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("richdocuments");
|
||||
skipUnlessServiceEnabled("collabora");
|
||||
test.setTimeout(120_000);
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.loginToStandaloneNextcloud(page);
|
||||
|
||||
const filesUrl = new URL("apps/files/", shared.env.nextcloudBaseUrl).toString();
|
||||
await page.goto(filesUrl, { waitUntil: "domcontentloaded", timeout: 60_000 });
|
||||
await shared.dismissBlockingNextcloudModals(page, page);
|
||||
|
||||
await expect(
|
||||
page.locator("#app-content, #app-content-vue, #app-navigation-vue").first(),
|
||||
"the Nextcloud Files app content must be visible with the Collabora (richdocuments) editor app registered",
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const { skipUnlessServiceEnabled } = require("../service-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
// `sociallogin` is the OIDC auth-path plugin selected when sso_oidc_plugin ==
|
||||
// "sociallogin" (OIDC-only flavor). It is not a distinct in-app page: its only
|
||||
// browser-reachable surface is the login journey itself. The shared
|
||||
// `loginToStandaloneNextcloud` flow already knows the "sociallogin" flavor (it
|
||||
// clicks the "Log in with Keycloak" entry before the credential form), so we
|
||||
// reuse it rather than reimplementing OIDC mechanics, and assert that an
|
||||
// authenticated Nextcloud shell results.
|
||||
test("addon sociallogin: nextcloud OIDC login surface authenticates", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("sociallogin");
|
||||
skipUnlessServiceEnabled("sso");
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.loginToStandaloneNextcloud(page);
|
||||
|
||||
const shellState = await shared.waitForVisibleCandidate(
|
||||
page,
|
||||
shared.getNextcloudShellCandidates(page),
|
||||
60_000,
|
||||
"Timed out waiting for an authenticated Nextcloud shell after the sociallogin OIDC login flow",
|
||||
);
|
||||
await expect(shellState.locator).toBeVisible();
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const { skipUnlessServiceEnabled } = require("../service-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
test.use({ ignoreHTTPSErrors: true });
|
||||
|
||||
test("spreed addon: Talk app loads", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("spreed");
|
||||
skipUnlessServiceEnabled("talk");
|
||||
test.setTimeout(120_000);
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.loginToStandaloneNextcloud(page);
|
||||
|
||||
const spreedUrl = new URL("apps/spreed/", shared.env.nextcloudBaseUrl).toString();
|
||||
await page.goto(spreedUrl, { waitUntil: "domcontentloaded", timeout: 60_000 });
|
||||
await shared.dismissBlockingNextcloudModals(page, page);
|
||||
|
||||
await expect(
|
||||
page.locator("#app-content, #content-vue").first(),
|
||||
"the Nextcloud Talk (spreed) app content must be visible",
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
test("addon tables: app route renders the Nextcloud app container", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("tables");
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.loginToStandaloneNextcloud(page);
|
||||
|
||||
const appUrl = new URL("apps/tables/", shared.env.nextcloudBaseUrl).toString();
|
||||
await page.goto(appUrl, { waitUntil: "commit", timeout: 60_000 });
|
||||
|
||||
await expect(
|
||||
page.locator("#app-content, #app-content-vue, #content").first()
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
} finally {
|
||||
await page.close();
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
test("addon tasks: app route renders the Nextcloud app container", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("tasks");
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.loginToStandaloneNextcloud(page);
|
||||
|
||||
const appUrl = new URL("apps/tasks/", shared.env.nextcloudBaseUrl).toString();
|
||||
await page.goto(appUrl, { waitUntil: "commit", timeout: 60_000 });
|
||||
|
||||
await expect(
|
||||
page.locator("#app-content, #app-content-vue, #content").first()
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
} finally {
|
||||
await page.close();
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
test.use({ ignoreHTTPSErrors: true });
|
||||
|
||||
// twofactor_nextcloud_notification adds a push/notification-based 2FA provider.
|
||||
// Its browser-observable surface is the per-user "Security" settings page, where
|
||||
// the 2FA section lists the available providers. The addon is only enabled when
|
||||
// SSO is disabled (see meta/addons/twofactor_nextcloud_notification.yml), so the
|
||||
// native NC login flow applies. Log in and assert the security settings page
|
||||
// renders.
|
||||
test("twofactor_nextcloud_notification addon: user security settings page renders", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("twofactor_nextcloud_notification");
|
||||
test.setTimeout(120_000);
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.loginToStandaloneNextcloud(page);
|
||||
|
||||
const securityUrl = new URL("settings/user/security", shared.env.nextcloudBaseUrl).toString();
|
||||
await page.goto(securityUrl, { waitUntil: "domcontentloaded", timeout: 60_000 });
|
||||
await shared.dismissBlockingNextcloudModals(page, page);
|
||||
|
||||
await expect(
|
||||
page.locator("#app-content, #app-content-vue, #content, #content-vue").first(),
|
||||
"the Nextcloud user security settings page must be visible (twofactor_nextcloud_notification surfaces in its 2FA section)",
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
test.use({ ignoreHTTPSErrors: true });
|
||||
|
||||
// twofactor_totp adds a TOTP authenticator provider. Its browser-observable
|
||||
// surface is the per-user "Security" settings page, where the 2FA section lists
|
||||
// the available providers. The addon is only enabled when SSO is disabled (see
|
||||
// meta/addons/twofactor_totp.yml), so the native NC login flow applies. Log in
|
||||
// and assert the security settings page renders.
|
||||
test("twofactor_totp addon: user security settings page renders", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("twofactor_totp");
|
||||
test.setTimeout(120_000);
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.loginToStandaloneNextcloud(page);
|
||||
|
||||
const securityUrl = new URL("settings/user/security", shared.env.nextcloudBaseUrl).toString();
|
||||
await page.goto(securityUrl, { waitUntil: "domcontentloaded", timeout: 60_000 });
|
||||
await shared.dismissBlockingNextcloudModals(page, page);
|
||||
|
||||
await expect(
|
||||
page.locator("#app-content, #app-content-vue, #content, #content-vue").first(),
|
||||
"the Nextcloud user security settings page must be visible (twofactor_totp surfaces in its 2FA section)",
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const { skipUnlessServiceEnabled } = require("../service-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
// `user_ldap` is the LDAP auth-path plugin (backs the native credential form
|
||||
// with the directory). It has no distinct in-app page; the deterministic
|
||||
// browser-reachable proof that LDAP federation is live is that the LDAP-only
|
||||
// `biber` persona — who has no local Nextcloud account — can authenticate and
|
||||
// land on an authenticated shell. Reuse the shared LDAP-first-login retry flow
|
||||
// rather than reimplementing LDAP mechanics. This is only meaningful in the
|
||||
// native+LDAP variant (no Keycloak handoff); the OIDC flavors are covered by
|
||||
// the OIDC addon specs, so skip when OIDC owns the login journey.
|
||||
test("addon user_ldap: biber authenticates via LDAP-backed native login", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("user_ldap");
|
||||
skipUnlessServiceEnabled("ldap");
|
||||
|
||||
test.skip(
|
||||
shared.env.nextcloudOidcEnabled,
|
||||
"user_ldap native-login proof is only meaningful when OIDC is off; the OIDC flavors authenticate biber through Keycloak and are covered by the OIDC addon specs.",
|
||||
);
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.loginToStandaloneNextcloudWithRetry(
|
||||
page,
|
||||
shared.env.biberUsername,
|
||||
shared.env.biberPassword,
|
||||
);
|
||||
|
||||
const shellState = await shared.waitForVisibleCandidate(
|
||||
page,
|
||||
shared.getNextcloudShellCandidates(page),
|
||||
60_000,
|
||||
"Timed out waiting for an authenticated Nextcloud shell after the biber LDAP login flow",
|
||||
);
|
||||
await expect(shellState.locator).toBeVisible();
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const { skipUnlessServiceEnabled } = require("../service-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
// `user_oidc` is the OIDC auth-path plugin selected when sso_oidc_plugin ==
|
||||
// "user_oidc". Like the other OIDC plugins it has no distinct in-app page; its
|
||||
// only browser-reachable surface is the login journey. Reuse the shared
|
||||
// `loginToStandaloneNextcloud` flow (which handles the Keycloak handoff for
|
||||
// every OIDC flavor) instead of reimplementing OIDC mechanics, and assert that
|
||||
// an authenticated Nextcloud shell results.
|
||||
test("addon user_oidc: nextcloud OIDC login surface authenticates", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("user_oidc");
|
||||
skipUnlessServiceEnabled("sso");
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.loginToStandaloneNextcloud(page);
|
||||
|
||||
const shellState = await shared.waitForVisibleCandidate(
|
||||
page,
|
||||
shared.getNextcloudShellCandidates(page),
|
||||
60_000,
|
||||
"Timed out waiting for an authenticated Nextcloud shell after the user_oidc OIDC login flow",
|
||||
);
|
||||
await expect(shellState.locator).toBeVisible();
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
test.use({ ignoreHTTPSErrors: true });
|
||||
|
||||
test("whiteboard addon: Files app loads with the Whiteboard editor registered", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("whiteboard");
|
||||
test.setTimeout(120_000);
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.loginToStandaloneNextcloud(page);
|
||||
|
||||
const filesUrl = new URL("apps/files/", shared.env.nextcloudBaseUrl).toString();
|
||||
await page.goto(filesUrl, { waitUntil: "domcontentloaded", timeout: 60_000 });
|
||||
await shared.dismissBlockingNextcloudModals(page, page);
|
||||
|
||||
await expect(
|
||||
page.locator("#app-content, #app-content-vue, #app-navigation-vue").first(),
|
||||
"the Nextcloud Files app content must be visible with the Whiteboard editor app registered",
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
const { test } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
|
||||
test.use({ ignoreHTTPSErrors: true });
|
||||
|
||||
test("xwiki addon: bridged XWiki partner has no in-app Nextcloud surface", async () => {
|
||||
skipUnlessAddonEnabled("xwiki");
|
||||
test.skip(
|
||||
true,
|
||||
"xwiki: bridged XWiki partner, no in-app web surface (covered by the partner web-app-xwiki role spec)"
|
||||
);
|
||||
});
|
||||
@@ -58,3 +58,7 @@ SEAWEEDFS_SERVICE_ENABLED={{ lookup('config', application_id, 'services.seaweedf
|
||||
|
||||
SSO_SERVICE_ENABLED={{ lookup('config', application_id, 'services.sso.enabled') | string | lower }}
|
||||
|
||||
{% for _aid, _aval in (lookup("config", application_id, "addons", {}) | dict2items) %}
|
||||
{{ _aid | upper | regex_replace("[^A-Z0-9]+", "_") }}_ADDON_ENABLED={{ (_aval.enabled | default(false)) | bool | lower }}
|
||||
{% endfor %}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
const { expect } = require("@playwright/test");
|
||||
const { decodeDotenvQuotedValue, performKeycloakLoginForm } = require("./personas");
|
||||
|
||||
const env = {
|
||||
odooBaseUrl: decodeDotenvQuotedValue(process.env.ODOO_BASE_URL || ""),
|
||||
oidcIssuerUrl: decodeDotenvQuotedValue(process.env.OIDC_ISSUER_URL || ""),
|
||||
adminUsername: decodeDotenvQuotedValue(process.env.ADMIN_USERNAME || ""),
|
||||
adminPassword: decodeDotenvQuotedValue(process.env.ADMIN_PASSWORD || ""),
|
||||
};
|
||||
|
||||
function baseUrl() {
|
||||
return env.odooBaseUrl.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
async function clickOdooSsoButton(locator) {
|
||||
const providerList = locator.locator(".o_login_auth, .o_auth_oauth_providers");
|
||||
const ssoButton = locator
|
||||
.locator('a[href*="/auth_oauth/signin"], a[href*="auth_oauth/signin"]')
|
||||
.filter({ hasText: /login with sso|sign in with|continue with/i })
|
||||
.first();
|
||||
const ssoButtonByText = locator.getByRole("link", { name: /login with sso/i }).first();
|
||||
|
||||
await Promise.any([
|
||||
providerList.first().waitFor({ state: "visible", timeout: 60_000 }),
|
||||
ssoButton.waitFor({ state: "visible", timeout: 60_000 }),
|
||||
ssoButtonByText.waitFor({ state: "visible", timeout: 60_000 }),
|
||||
]);
|
||||
|
||||
if (await ssoButtonByText.isVisible().catch(() => false)) {
|
||||
await ssoButtonByText.click();
|
||||
} else {
|
||||
await ssoButton.click();
|
||||
}
|
||||
}
|
||||
|
||||
async function loginToOdoo(page) {
|
||||
const expectedBaseUrl = baseUrl();
|
||||
await page.goto(`${expectedBaseUrl}/web/login`, { waitUntil: "domcontentloaded", timeout: 60_000 });
|
||||
|
||||
await clickOdooSsoButton(page);
|
||||
|
||||
await expect
|
||||
.poll(() => page.url(), {
|
||||
timeout: 60_000,
|
||||
message: "Expected page to navigate to Keycloak for authentication",
|
||||
})
|
||||
.toContain(env.oidcIssuerUrl.replace(/\/$/, ""));
|
||||
|
||||
await performKeycloakLoginForm(page, env.adminUsername, env.adminPassword);
|
||||
|
||||
await expect
|
||||
.poll(() => page.url(), {
|
||||
timeout: 60_000,
|
||||
message: "Expected page to navigate back to authenticated Odoo (not login page)",
|
||||
})
|
||||
.toMatch(new RegExp(`^${expectedBaseUrl.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?!/web/login)`));
|
||||
}
|
||||
|
||||
async function openModule(page, modulePath) {
|
||||
const url = `${baseUrl()}/${String(modulePath).replace(/^\//, "")}`;
|
||||
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60_000 });
|
||||
const appShell = page.locator(
|
||||
".o_web_client, .o_action_manager, .o_main_navbar, .o_content, .o_list_view, .o_kanban_view"
|
||||
);
|
||||
await expect(appShell.first()).toBeVisible({ timeout: 60_000 });
|
||||
}
|
||||
|
||||
module.exports = { env, baseUrl, clickOdooSsoButton, loginToOdoo, openModule };
|
||||
@@ -0,0 +1,23 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
test("addon account: invoicing module UI renders", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("account");
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.loginToOdoo(page);
|
||||
await shared.openModule(page, "odoo/accounting");
|
||||
|
||||
const surface = page.locator(
|
||||
".o_web_client, .o_action_manager, .o_main_navbar, .o_content, .o_list_view, .o_kanban_view"
|
||||
);
|
||||
await expect(surface.first()).toBeVisible({ timeout: 60_000 });
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
test("addon contacts: contacts module UI renders", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("contacts");
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.loginToOdoo(page);
|
||||
await shared.openModule(page, "odoo/contacts");
|
||||
|
||||
const surface = page.locator(
|
||||
".o_web_client, .o_action_manager, .o_main_navbar, .o_content, .o_kanban_view"
|
||||
);
|
||||
await expect(surface.first()).toBeVisible({ timeout: 60_000 });
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
test("addon crm: CRM module UI renders", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("crm");
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.loginToOdoo(page);
|
||||
await shared.openModule(page, "odoo/crm");
|
||||
|
||||
const surface = page.locator(
|
||||
".o_web_client, .o_action_manager, .o_main_navbar, .o_content, .o_kanban_view"
|
||||
);
|
||||
await expect(surface.first()).toBeVisible({ timeout: 60_000 });
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
test("addon project: project module UI renders", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("project");
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.loginToOdoo(page);
|
||||
await shared.openModule(page, "odoo/project");
|
||||
|
||||
const surface = page.locator(
|
||||
".o_web_client, .o_action_manager, .o_main_navbar, .o_content, .o_kanban_view"
|
||||
);
|
||||
await expect(surface.first()).toBeVisible({ timeout: 60_000 });
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
test("addon sale_management: sales module UI renders", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("sale_management");
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.loginToOdoo(page);
|
||||
await shared.openModule(page, "odoo/sales");
|
||||
|
||||
const surface = page.locator(
|
||||
".o_web_client, .o_action_manager, .o_main_navbar, .o_content, .o_list_view, .o_kanban_view"
|
||||
);
|
||||
await expect(surface.first()).toBeVisible({ timeout: 60_000 });
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
test("addon stock: inventory module UI renders", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("stock");
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.loginToOdoo(page);
|
||||
await shared.openModule(page, "odoo/inventory");
|
||||
|
||||
const surface = page.locator(
|
||||
".o_web_client, .o_action_manager, .o_main_navbar, .o_content, .o_kanban_view"
|
||||
);
|
||||
await expect(surface.first()).toBeVisible({ timeout: 60_000 });
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
test("addon website: website module UI renders", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("website");
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.loginToOdoo(page);
|
||||
await shared.openModule(page, "odoo/website");
|
||||
|
||||
const surface = page.locator(
|
||||
".o_web_client, .o_action_manager, .o_main_navbar, .o_content, .o_website_preview, iframe.o_iframe"
|
||||
);
|
||||
await expect(surface.first()).toBeVisible({ timeout: 60_000 });
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -36,3 +36,7 @@ PERSONA_BIBER_BLOCKED=true
|
||||
|
||||
SSO_SERVICE_ENABLED={{ lookup('config', application_id, 'services.sso.enabled') | string | lower }}
|
||||
|
||||
{% for _aid, _aval in (lookup("config", application_id, "addons", {}) | dict2items) %}
|
||||
{{ _aid | upper | regex_replace("[^A-Z0-9]+", "_") }}_ADDON_ENABLED={{ (_aval.enabled | default(false)) | bool | lower }}
|
||||
{% endfor %}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const { skipUnlessServiceEnabled } = require("../service-gating");
|
||||
const { runAdminFlow } = require("../personas");
|
||||
|
||||
// pretix-oidc is Pretix's OIDC SSO plugin: it has no dedicated in-app
|
||||
// user page. Its observable surface is that the role's existing admin
|
||||
// OIDC login idiom (runAdminFlow → Keycloak round-trip → authenticated
|
||||
// Pretix) succeeds. We REUSE that idiom rather than reimplement OIDC
|
||||
// mechanics, gating behind the addon flag and the sso service.
|
||||
test.use({ ignoreHTTPSErrors: true });
|
||||
|
||||
test("addon pretix-oidc: administrator OIDC login round-trip succeeds", async ({ page }) => {
|
||||
skipUnlessAddonEnabled("pretix-oidc");
|
||||
skipUnlessServiceEnabled("sso");
|
||||
|
||||
await runAdminFlow(page, {
|
||||
adminInteraction: async (interactivePage) => {
|
||||
const link = interactivePage
|
||||
.getByRole("link", { name: /^(events|orders|control|admin)$/i })
|
||||
.first();
|
||||
if (await link.isVisible({ timeout: 10_000 }).catch(() => false)) {
|
||||
await link.click().catch(() => {});
|
||||
await interactivePage
|
||||
.waitForLoadState("domcontentloaded", { timeout: 30_000 })
|
||||
.catch(() => {});
|
||||
await expect(interactivePage.locator("body")).toContainText(
|
||||
/event|order|ticket|control|pretix/i,
|
||||
{ timeout: 30_000 }
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
test("addon activitypub: settings page reachable in wp-admin", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("activitypub");
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.wpAdminLoginViaOidc(
|
||||
page,
|
||||
shared.env.wpBaseUrl,
|
||||
shared.env.adminUsername,
|
||||
shared.env.adminPassword
|
||||
);
|
||||
|
||||
await page.goto(
|
||||
`${shared.env.wpBaseUrl}/wp-admin/options-general.php?page=activitypub`,
|
||||
{ waitUntil: "domcontentloaded", timeout: 60_000 }
|
||||
);
|
||||
|
||||
const settingsSurface = page
|
||||
.locator(
|
||||
"form#activitypub-settings, .activitypub-settings-page, .activitypub_settings_page, #wpbody-content .wrap"
|
||||
)
|
||||
.filter({ hasText: /activitypub|fediverse|federation/i })
|
||||
.first();
|
||||
|
||||
await expect(
|
||||
settingsSurface,
|
||||
"Expected the ActivityPub settings surface in wp-admin"
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const { skipUnlessServiceEnabled } = require("../service-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
// daggerhart-openid-connect-generic is WordPress's OIDC login plugin: it
|
||||
// has no dedicated user-facing page. Its observable surface is that the
|
||||
// role's existing administrator OIDC login idiom (wpAdminLoginViaOidc →
|
||||
// Keycloak round-trip → wp-admin) succeeds. We REUSE that idiom rather
|
||||
// than reimplement OIDC mechanics, gating behind the addon flag and the
|
||||
// sso service.
|
||||
test("addon daggerhart-openid-connect-generic: administrator OIDC login lands in wp-admin", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("daggerhart-openid-connect-generic");
|
||||
skipUnlessServiceEnabled("sso");
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.wpAdminLoginViaOidc(
|
||||
page,
|
||||
shared.env.wpBaseUrl,
|
||||
shared.env.adminUsername,
|
||||
shared.env.adminPassword
|
||||
);
|
||||
|
||||
await expect(page, "Expected to land in wp-admin after the OIDC round-trip").toHaveURL(
|
||||
/\/wp-admin\/?/,
|
||||
{ timeout: 30_000 }
|
||||
);
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const { skipUnlessServiceEnabled } = require("../service-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
// infinito-oidc-rbac-mapper is a vendored mu-plugin: it maps Keycloak
|
||||
// `groups` claims to WordPress roles at OIDC login time and has no
|
||||
// dedicated configuration page. Its observable browser surface is the
|
||||
// wp-admin users list, where mapped roles are rendered in the Role
|
||||
// column (the full mapping correctness is exercised end-to-end by
|
||||
// test-rbac-roles.js). We log in as administrator via the role's OIDC
|
||||
// idiom and assert that the role-bearing users surface renders, gating
|
||||
// behind the addon flag plus the sso and ldap services that supply the
|
||||
// group claims.
|
||||
test("addon infinito-oidc-rbac-mapper: wp-admin users list exposes the role-mapping surface", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("infinito-oidc-rbac-mapper");
|
||||
skipUnlessServiceEnabled("sso");
|
||||
skipUnlessServiceEnabled("ldap");
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.wpAdminLoginViaOidc(
|
||||
page,
|
||||
shared.env.wpBaseUrl,
|
||||
shared.env.adminUsername,
|
||||
shared.env.adminPassword
|
||||
);
|
||||
|
||||
await page.goto(`${shared.env.wpBaseUrl}/wp-admin/users.php`, {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
const roleSurface = page
|
||||
.locator("#the-list tr, table.users th#role, #wpbody-content .wrap")
|
||||
.first();
|
||||
await expect(
|
||||
roleSurface,
|
||||
"Expected the wp-admin users list (RBAC role-mapping surface) to render"
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
test("addon wp-discourse: settings page reachable in wp-admin", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("wp-discourse");
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await shared.wpAdminLoginViaOidc(
|
||||
page,
|
||||
shared.env.wpBaseUrl,
|
||||
shared.env.adminUsername,
|
||||
shared.env.adminPassword
|
||||
);
|
||||
|
||||
await page.goto(
|
||||
`${shared.env.wpBaseUrl}/wp-admin/admin.php?page=wp_discourse_options`,
|
||||
{ waitUntil: "domcontentloaded", timeout: 60_000 }
|
||||
);
|
||||
|
||||
const settingsSurface = page
|
||||
.locator(
|
||||
"form#wp-discourse-form, .wp-discourse-options, #wpbody-content .wrap, .nav-tab-wrapper"
|
||||
)
|
||||
.filter({ hasText: /discourse/i })
|
||||
.first();
|
||||
|
||||
await expect(
|
||||
settingsSurface,
|
||||
"Expected the WP Discourse settings surface in wp-admin"
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -44,6 +44,11 @@ RBAC_GROUP_PATH_PREFIX={{ ('/' ~ (RBAC.GROUP.NAME | trim('/')) ~ '/' ~ applicati
|
||||
|
||||
SSO_SERVICE_ENABLED={{ lookup('service', 'sso').required | string | lower }}
|
||||
|
||||
# Addon gate flags. `tests/addon-gating.js` reads these; one line per declared addon.
|
||||
{% for _aid, _aval in (lookup("config", application_id, "addons", {}) | dict2items) %}
|
||||
{{ _aid | upper | regex_replace("[^A-Z0-9]+", "_") }}_ADDON_ENABLED={{ (_aval.enabled | default(false)) | bool | lower }}
|
||||
{% endfor %}
|
||||
|
||||
SUPER_ADMIN_PASSWORD={{ lookup('config', 'web-app-keycloak', 'credentials.administrator_password') | dotenv_quote }}
|
||||
|
||||
SUPER_ADMIN_USERNAME={{ lookup('config', 'web-app-keycloak', 'services.keycloak.accounts.system.username') | dotenv_quote }}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const { skipUnlessServiceEnabled } = require("../service-gating");
|
||||
const {
|
||||
decodeDotenvQuotedValue,
|
||||
normalizeBaseUrl,
|
||||
runAdminFlow,
|
||||
} = require("../personas");
|
||||
|
||||
test.use({ ignoreHTTPSErrors: true });
|
||||
|
||||
const appBaseUrl = normalizeBaseUrl(process.env.APP_BASE_URL || "");
|
||||
const canonicalDomain = decodeDotenvQuotedValue(process.env.CANONICAL_DOMAIN || "");
|
||||
|
||||
test("ldap-authenticator: LDAP-backed login reaches an authenticated XWiki surface", async ({ page }) => {
|
||||
skipUnlessAddonEnabled("ldap-authenticator");
|
||||
skipUnlessServiceEnabled("ldap");
|
||||
|
||||
expect(appBaseUrl, "APP_BASE_URL must be set").toBeTruthy();
|
||||
expect(canonicalDomain, "CANONICAL_DOMAIN must be set").toBeTruthy();
|
||||
|
||||
await page.context().clearCookies();
|
||||
|
||||
await runAdminFlow(page, {
|
||||
adminInteraction: async (interactivePage) => {
|
||||
await expect(interactivePage.locator("body")).toContainText(
|
||||
/wiki|edit|page|administration|xwiki|logout|profile/i,
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const { skipUnlessServiceEnabled } = require("../service-gating");
|
||||
const {
|
||||
decodeDotenvQuotedValue,
|
||||
normalizeBaseUrl,
|
||||
runAdminFlow,
|
||||
} = require("../personas");
|
||||
|
||||
test.use({ ignoreHTTPSErrors: true });
|
||||
|
||||
const appBaseUrl = normalizeBaseUrl(process.env.APP_BASE_URL || "");
|
||||
const canonicalDomain = decodeDotenvQuotedValue(process.env.CANONICAL_DOMAIN || "");
|
||||
|
||||
test("oidc-authenticator: SSO login reaches an authenticated XWiki surface", async ({ page }) => {
|
||||
skipUnlessAddonEnabled("oidc-authenticator");
|
||||
skipUnlessServiceEnabled("sso");
|
||||
|
||||
expect(appBaseUrl, "APP_BASE_URL must be set").toBeTruthy();
|
||||
expect(canonicalDomain, "CANONICAL_DOMAIN must be set").toBeTruthy();
|
||||
|
||||
await page.context().clearCookies();
|
||||
|
||||
await runAdminFlow(page, {
|
||||
adminInteraction: async (interactivePage) => {
|
||||
await expect(interactivePage.locator("body")).toContainText(
|
||||
/wiki|edit|page|administration|xwiki|logout|profile/i,
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Lint: EVERY addon of EVERY role ships a per-addon Playwright spec.
|
||||
|
||||
For each ``roles/<role>/meta/addons/<id>.yml`` a spec MUST exist at
|
||||
``roles/<role>/files/playwright/addons/<id>.spec.js``. There is no exemption:
|
||||
no mechanism auto-exempt and no ``# nocheck`` escape. Every addon is a
|
||||
declared behaviour of the deployment and therefore carries its own spec
|
||||
(gated with ``skipUnlessAddonEnabled`` so it skips cleanly when the addon is
|
||||
not enabled in the current variant).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from utils.update.addons import iter_addon_files
|
||||
|
||||
from . import PROJECT_ROOT
|
||||
|
||||
|
||||
class TestAddonsPlaywrightSpec(unittest.TestCase):
|
||||
def test_every_addon_has_a_spec(self) -> None:
|
||||
roles_root = PROJECT_ROOT / "roles"
|
||||
if not roles_root.is_dir():
|
||||
self.skipTest("no roles/ directory")
|
||||
|
||||
errors: list[str] = []
|
||||
for role, addon_file in iter_addon_files(roles_root):
|
||||
addon_id = addon_file.stem
|
||||
rel = addon_file.relative_to(PROJECT_ROOT).as_posix()
|
||||
spec_path = (
|
||||
roles_root
|
||||
/ role
|
||||
/ "files"
|
||||
/ "playwright"
|
||||
/ "addons"
|
||||
/ f"{addon_id}.spec.js"
|
||||
)
|
||||
if not spec_path.is_file():
|
||||
errors.append(
|
||||
f"{role}: addons.{addon_id} has no Playwright spec at "
|
||||
f"{spec_path.relative_to(PROJECT_ROOT).as_posix()} "
|
||||
f"(declared in {rel}); every addon MUST ship a spec."
|
||||
)
|
||||
|
||||
if errors:
|
||||
self.fail(
|
||||
f"playwright per-addon spec violations ({len(errors)}):\n"
|
||||
+ "\n".join(f" - {e}" for e in errors)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
unittest.main()
|
||||
@@ -177,6 +177,12 @@ def validate_env_file(path: Path, *, strict: bool) -> list[Violation]:
|
||||
match = _ENTRY_RE.match(raw)
|
||||
|
||||
if match is None:
|
||||
if is_jinja and ("{{" in key_part or "{%" in key_part):
|
||||
# Dynamic templated key (e.g. a {% for %}-generated KEY in a
|
||||
# .j2 template); the rendered keys are validated at deploy
|
||||
# time, not by this static scan.
|
||||
pending = []
|
||||
continue
|
||||
if key_part != key_part.rstrip() or value_part != value_part.lstrip():
|
||||
violations.append(
|
||||
Violation(
|
||||
|
||||
Reference in new issue
Block a user