Files
computer-playbook/roles/web-svc-libretranslate/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

94 lines
4.0 KiB
JavaScript

const { test, expect } = require("@playwright/test");
const { resolveTimeout } = require("./timeouts");
const { skipUnlessServiceEnabled } = require("./service-gating");
const { decodeDotenvQuotedValue, gotoOnion, normalizeBaseUrl, performKeycloakLoginForm, runBiberFlow, runGuestFlow } = require("./personas");
test.use({ ignoreHTTPSErrors: true });
require("./test-mcp-guest").register();
const baseUrl = normalizeBaseUrl(process.env.LIBRETRANSLATE_BASE_URL || "");
const oidcIssuerUrl = normalizeBaseUrl(process.env.OIDC_ISSUER_URL || "");
const adminUsername = decodeDotenvQuotedValue(process.env.ADMIN_USERNAME);
const adminPassword = decodeDotenvQuotedValue(process.env.ADMIN_PASSWORD);
const canonicalDomain = decodeDotenvQuotedValue(process.env.CANONICAL_DOMAIN || "");
test.beforeEach(async ({ page }) => {
expect(baseUrl, "LIBRETRANSLATE_BASE_URL must be set").toBeTruthy();
expect(canonicalDomain, "CANONICAL_DOMAIN must be set").toBeTruthy();
await page.context().clearCookies();
});
test("libretranslate /languages API stays reachable without auth", async ({ request }) => {
// The /languages endpoint is part of the API surface that
// LibreTranslate authenticates with API keys; per requirement
// 013 it MUST stay reachable even when the UI is OIDC-gated by
// the oauth2-proxy sidecar. The role's `services.sso.oauth2.acl.whitelist`
// includes /languages, /translate, /detect for exactly this reason.
const response = await request.get(`${baseUrl}/languages`, { timeout: resolveTimeout(30_000) });
expect(response.status(), "Expected libretranslate /languages status < 400 without auth").toBeLessThan(400);
const contentType = response.headers()["content-type"] || "";
expect(
contentType.includes("application/json"),
`Expected JSON content-type for /languages, got "${contentType}"`
).toBe(true);
});
test("administrator: oauth2-proxy gates the LibreTranslate UI through Keycloak", async ({ page }) => {
skipUnlessServiceEnabled("sso");
expect(oidcIssuerUrl, "OIDC_ISSUER_URL must be set when OIDC is enabled").toBeTruthy();
expect(adminUsername, "ADMIN_USERNAME must be set").toBeTruthy();
expect(adminPassword, "ADMIN_PASSWORD must be set").toBeTruthy();
// The whole `/` (UI) path is gated by oauth2-proxy; navigating to
// it triggers a redirect chain to oauth2-proxy and then to
// Keycloak's authorization endpoint.
const expectedOidcAuthUrl = `${oidcIssuerUrl}/protocol/openid-connect/auth`;
await gotoOnion(page, `${baseUrl}/`);
await expect
.poll(() => page.url(), {
timeout: resolveTimeout(60_000),
message: `expected redirect to Keycloak OIDC auth (${expectedOidcAuthUrl})`
})
.toContain(expectedOidcAuthUrl);
await performKeycloakLoginForm(page, adminUsername, adminPassword);
await expect
.poll(() => page.url(), {
timeout: resolveTimeout(60_000),
message: `expected redirect back to LibreTranslate at ${baseUrl}`
})
.toContain(canonicalDomain);
// After OIDC the UI MUST render. LibreTranslate's frontend exposes
// the source/target language selectors and the translate button on
// the landing page.
await expect(page.locator("body")).toContainText(/translate|source|target|language/i, { timeout: resolveTimeout(60_000) });
// Logout via oauth2-proxy's sign-out endpoint and confirm the gate
// re-engages.
await gotoOnion(page, `${baseUrl}/oauth2/sign_out`, { waitUntil: "commit" }).catch(() => {});
await page.context().clearCookies();
await gotoOnion(page, `${baseUrl}/`);
await expect
.poll(() => page.url(), {
timeout: resolveTimeout(60_000),
message: "expected the UI to redirect to Keycloak again after logout"
})
.toContain(expectedOidcAuthUrl);
});
// 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);
});