mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
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>
94 lines
4.0 KiB
JavaScript
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);
|
|
});
|