Files
computer-playbook/roles/web-app-funkwhale/files/playwright/test-native-login.js
kevinveenbirkenbachandClaude Opus 5 801923eb31 fix(funkwhale): let the log-out request finish before leaving the page
b6e3368a2 added the click on Funkwhale's own confirmation ("Yes, log me out!"), which fires POST /api/v2/users/logout. c7e5b0a27 then added the return to appBaseUrl so the assertion lands on Funkwhale rather than on the logout page. Each is right on its own; together the navigation aborts the request 9ms after it starts.

Measured in all three traces of job 95883448598: the POST carries status -1 with send/wait/receive all -1 - never answered, aborted. Click at 59744ms, POST at 59876ms, goto at 59885ms. The WebSocket that follows still carries sessionid=bam0ska8z0qkbtegsp10iot028cbcsr9, and openresty logs the matching 499. Only swarm+tor is slow enough to lose the race; the other seven Funkwhale variants of that run are green.

The catch keeps those seven safe: whether their confirm click fires the same XHR is unverified, and without it a missing POST would turn them red. Promise.all rather than sequential awaits because waitForResponse has to register before the click.

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

130 lines
4.8 KiB
JavaScript

const { test, expect } = require("@playwright/test");
const { resolveTimeout } = require("./timeouts");
const { decodeDotenvQuotedValue, isAuthChain, normalizeBaseUrl , gotoOnion } = require("./personas");
const { performKeycloakLoginForm } = require("./personas/utils/keycloak");
const appBaseUrl = normalizeBaseUrl(process.env.APP_BASE_URL || "");
const canonicalDomain = decodeDotenvQuotedValue(process.env.CANONICAL_DOMAIN || "");
const ldapEnabled = (process.env.LDAP_SERVICE_ENABLED || "").toLowerCase() === "true";
const ssoEnabled = (process.env.SSO_SERVICE_ENABLED || "").toLowerCase() === "true";
const PERSONAS = [
{
label: "biber",
username: decodeDotenvQuotedValue(process.env.BIBER_USERNAME || ""),
password: decodeDotenvQuotedValue(process.env.BIBER_PASSWORD || ""),
},
{
label: "administrator",
username: decodeDotenvQuotedValue(process.env.ADMIN_USERNAME || ""),
password: decodeDotenvQuotedValue(process.env.ADMIN_PASSWORD || ""),
},
];
const signInAffordance = (page) => page.getByText(/^\s*(log\s?in|sign\s?in)\s*$/i).first();
const hostOf = (page) => {
try {
return new URL(page.url()).hostname;
} catch {
return "";
}
};
const onIdentityProvider = (page) => isAuthChain(page.url()) && hostOf(page) !== canonicalDomain;
const backOnApp = (page) => hostOf(page) === canonicalDomain && !isAuthChain(page.url());
for (const persona of PERSONAS) {
test(`${persona.label}: native sign-in → authenticated surface → sign-out`, async ({ page }) => {
test.skip(
!ldapEnabled,
"LDAP_SERVICE_ENABLED=false: tasks/main.yml provisions no local Funkwhale account, so no persona can sign in.",
);
test.setTimeout(resolveTimeout(180_000));
expect(persona.username, `${persona.label} username must be set`).toBeTruthy();
expect(persona.password, `${persona.label} password must be set`).toBeTruthy();
await page.context().clearCookies();
await gotoOnion(page, `${appBaseUrl}/login`, { waitUntil: "domcontentloaded" });
if (ssoEnabled) {
await expect
.poll(() => onIdentityProvider(page), {
timeout: resolveTimeout(60_000),
message: "the oauth2 ACL protects /login, so the proxy must hand the persona to Keycloak",
})
.toBe(true);
await performKeycloakLoginForm(page, persona.username, persona.password);
await expect
.poll(() => backOnApp(page), {
timeout: resolveTimeout(60_000),
message: `the oauth2 proxy gates /login, so Keycloak must hand back to ${canonicalDomain}`,
})
.toBe(true);
}
const password = page.locator("input[type='password']:visible").first();
await expect(
password,
"Funkwhale serves its own sign-in form on /login; the proxy only gates the route, it does not create an application session",
).toBeVisible({ timeout: resolveTimeout(60_000) });
await page
.locator(
"input[autocomplete='username']:visible, input[type='text']:visible, input[type='email']:visible",
)
.first()
.fill(persona.username);
await password.fill(persona.password);
await password.press("Enter");
await expect(
signInAffordance(page),
`the sidebar must stop offering sign-in once ${persona.label} holds a Funkwhale session`,
).toBeHidden({ timeout: resolveTimeout(60_000) });
await expect(page.locator("body")).toContainText(/library|playlist|artist|album|track|channel/i, {
timeout: resolveTimeout(60_000),
});
const signOut = page
.getByRole("link", { name: /log\s?out|sign\s?out/i })
.or(page.getByRole("button", { name: /log\s?out|sign\s?out/i }))
.first();
const signOutReachable = await signOut
.waitFor({ state: "visible", timeout: resolveTimeout(10_000) })
.then(() => true)
.catch(() => false);
if (signOutReachable) {
await signOut.click();
} else {
await gotoOnion(page, `${appBaseUrl}/logout`, { waitUntil: "domcontentloaded" });
}
const confirmSignOut = page.getByRole("button", { name: /(log|sign)\s?(me\s?)?out/i }).first();
const confirmReachable = await confirmSignOut
.waitFor({ state: "visible", timeout: resolveTimeout(10_000) })
.then(() => true)
.catch(() => false);
if (confirmReachable) {
await Promise.all([
page
.waitForResponse(
(r) => r.url().includes("/api/v2/users/logout") && r.request().method() === "POST",
{ timeout: resolveTimeout(15_000) },
)
.catch(() => null),
confirmSignOut.click(),
]);
}
await gotoOnion(page, appBaseUrl, { waitUntil: "domcontentloaded" });
await expect(
signInAffordance(page),
`after sign-out Funkwhale must offer sign-in to ${persona.label} again`,
).toBeVisible({ timeout: resolveTimeout(60_000) });
});
}