Files
computer-playbook/roles/web-app-fider/files/playwright/playwright.spec.js
kevinveenbirkenbachandClaude Opus 5 c94a0a0b5d test(mcp): ask the ten enabled surfaces the hostile question
Nine roles enabled an MCP surface without a browser-side spec, so nothing
ever asked what the endpoint answers a caller with no credential at all.
Lift gitea's assertion into two shared registrars and register them per
role, and give each role the MCP_SERVICE_ENABLED flag they gate on.

mcpEndpointUrl now takes the base URL rather than reading APP_BASE_URL
unconditionally: jellyfin and zammad export their own base variable, and
declaring APP_BASE_URL for them would un-skip the shared guest persona,
which collapses itself on that variable being absent.

The env-gate scanner follows require("./x") into the shared harness so a
gate declared there counts as consumed, and strips block comments first
so service-gating.js's own doc examples do not credit EMAIL, SSO and OICD
to every role that imports it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 16:06:19 +02:00

170 lines
7.7 KiB
JavaScript

const { test, expect } = require("@playwright/test");
const { resolveTimeout } = require("./timeouts");
const { decodeDotenvQuotedValue, gotoOnion, performKeycloakLoginForm, runAdminFlow, runBiberFlow, runGuestFlow } = require("./personas");
const { isServiceEnabled } = require("./service-gating");
require("./test-seaweedfs");
require("./test-mcp-guest").register();
test.use({
ignoreHTTPSErrors: true
});
const oidcEnabled = isServiceEnabled("sso");
// `docker --env-file` preserves the quotes emitted by `dotenv_quote`,
// so normalize these values before building URLs or typing credentials.
const oidcIssuerUrl = decodeDotenvQuotedValue(process.env.OIDC_ISSUER_URL);
const fiderBaseUrl = decodeDotenvQuotedValue(process.env.FIDER_BASE_URL);
const adminUsername = decodeDotenvQuotedValue(process.env.ADMIN_USERNAME);
const adminPassword = decodeDotenvQuotedValue(process.env.ADMIN_PASSWORD);
const biberUsername = decodeDotenvQuotedValue(process.env.BIBER_USERNAME);
const biberPassword = decodeDotenvQuotedValue(process.env.BIBER_PASSWORD);
// Click through Fider's sign-in page to reach the SSO provider button.
// Fider shows a "Sign in" button in the header, then a modal listing OAuth providers.
async function clickFiderSsoButton(locator) {
// Click "Sign in" in the Fider header
const signInLink = locator
.getByRole("button", { name: /sign in/i })
.or(locator.getByRole("link", { name: /sign in/i }));
await signInLink.first().waitFor({ state: "visible", timeout: resolveTimeout(30_000) });
await signInLink.first().click();
// Fider shows a "Join the conversation" modal with a "Continue with ... SSO" button.
// The display_name is set to "{{ SOFTWARE_NAME }} SSO" = "Infinito.Nexus SSO".
const ssoButton = locator.getByRole("link", { name: /continue with/i });
await ssoButton.first().waitFor({ state: "visible", timeout: resolveTimeout(15_000) });
// force: true bypasses aria-disabled which Fider sets on the button during modal render
await ssoButton.first().click({ force: true, timeout: resolveTimeout(30_000) });
}
test.beforeEach(() => {
expect(oidcIssuerUrl, "OIDC_ISSUER_URL must be set in the Playwright env file").toBeTruthy();
expect(fiderBaseUrl, "FIDER_BASE_URL must be set in the Playwright env file").toBeTruthy();
expect(adminUsername, "ADMIN_USERNAME must be set in the Playwright env file").toBeTruthy();
expect(adminPassword, "ADMIN_PASSWORD must be set in the Playwright env file").toBeTruthy();
expect(biberUsername, "BIBER_USERNAME must be set in the Playwright env file").toBeTruthy();
expect(biberPassword, "BIBER_PASSWORD must be set in the Playwright env file").toBeTruthy();
});
// Scenario I: Fider → SSO login as admin → verify admin UI → logout
// (dashboard-iframe mechanics covered by web-app-dashboard SPOT)
test("fider: admin sso login, verify ui, logout", async ({ page }) => {
test.skip(!oidcEnabled, "OIDC shared service disabled");
const expectedFiderBaseUrl = fiderBaseUrl.replace(/\/$/, "");
// 1. Navigate directly to Fider
await gotoOnion(page, `${expectedFiderBaseUrl}/`);
// 2. Click through Fider's sign-in flow to trigger the Keycloak SSO redirect
await clickFiderSsoButton(page);
// 3. Fider redirects to Keycloak for SSO login.
// (performKeycloakLoginForm waits for the username field before filling credentials.)
await performKeycloakLoginForm(page, adminUsername, adminPassword);
// 4. After login Fider redirects back — verify the admin
// is logged in (.c-menu-user is only rendered when fider.session.isAuthenticated)
await expect(page.locator(".c-menu-user").first()).toBeVisible({ timeout: resolveTimeout(60_000) });
// 5. Logout — navigate to Fider's sign-out endpoint
await gotoOnion(page, `${expectedFiderBaseUrl}/signout`, { waitUntil: "domcontentloaded" }).catch(() => {});
// 6. Verify signout — the public Fider page should no longer show the user menu
await gotoOnion(page, `${expectedFiderBaseUrl}/`);
await expect(page.locator(".c-menu-user")).not.toBeAttached({ timeout: resolveTimeout(10_000) });
});
// Scenario II: biber logs in directly to Fider as a regular user → verifies access → logs out
//
// Fider is a public platform — any authenticated Keycloak user can log in.
// biber should be able to access Fider but will have a regular user role (not admin).
// This test verifies that the SSO flow works for non-admin users and that they land
// on the Fider main page (not an admin panel).
test("fider: biber sso login as regular user, verify access, logout", async ({ browser }) => {
test.skip(!oidcEnabled, "OIDC shared service disabled");
const expectedOidcAuthUrl = `${oidcIssuerUrl.replace(/\/$/, "")}/protocol/openid-connect/auth`;
const expectedFiderBaseUrl = fiderBaseUrl.replace(/\/$/, "");
// Isolated context — no shared session with other tests
const biberContext = await browser.newContext({ ignoreHTTPSErrors: true });
try {
const biberPage = await biberContext.newPage();
// 1. Navigate directly to Fider
await biberPage.goto(`${expectedFiderBaseUrl}/`);
// 2. Fider public page loads — click Sign in → SSO provider
await clickFiderSsoButton(biberPage);
// 3. Wait for Keycloak OIDC auth
await expect
.poll(() => biberPage.url(), {
timeout: resolveTimeout(30_000),
message: `Expected redirect to Keycloak OIDC auth: ${expectedOidcAuthUrl}`
})
.toContain(expectedOidcAuthUrl);
// 4. Log in as biber
await performKeycloakLoginForm(biberPage, biberUsername, biberPassword);
// 5. After login Fider redirects back
await expect
.poll(() => biberPage.url(), {
timeout: resolveTimeout(60_000),
message: `Expected redirect back to Fider after biber login: ${expectedFiderBaseUrl}`
})
.toContain(expectedFiderBaseUrl);
// 6. Verify biber is logged in — .c-menu-user is only rendered when authenticated
await expect(biberPage.locator(".c-menu-user").first()).toBeVisible({ timeout: resolveTimeout(30_000) });
// 7. Verify biber is NOT shown admin controls.
// The admin link is inside the dropdown AND gated by isCollaborator — so it is
// never in the DOM for a regular user (regardless of dropdown state).
await expect(biberPage.locator("a[href='/admin']").first()).not.toBeAttached({ timeout: resolveTimeout(5_000) });
// 8. Logout
await biberPage.goto(`${expectedFiderBaseUrl}/signout`, { waitUntil: "domcontentloaded" }).catch(() => {});
} finally {
await biberContext.close().catch(() => {});
}
});
// Persona scenarios.
// Bodies live in the shared helper roles/test-e2e-playwright/files/personas.js
// so every role's persona flow stays consistent.
test("guest: public-landing → auth chain → never authenticated", async ({ page }) => {
await runGuestFlow(page);
});
test("biber: app → universal logout", async ({ page }) => {
await runBiberFlow(page);
});
test("administrator: app → universal logout", async ({ page }) => {
await runAdminFlow(page, {
adminInteraction: async (interactivePage) => {
// web-app-fider admin-only interaction: open a management surface.
const link = interactivePage
.getByRole("link", { name: /^(admin|posts|users|invitations|settings)$/i })
.first();
if (await link.isVisible().catch(() => false)) {
await link.click({ timeout: resolveTimeout(30_000) }).catch(() => {});
await interactivePage.waitForLoadState("domcontentloaded", { timeout: resolveTimeout(30_000) }).catch(() => {});
await expect(interactivePage.locator("body")).toContainText(
/posts|administration|invitations|users|settings|tags/i,
{ timeout: resolveTimeout(30_000) },
);
}
},
});
});