Files
kevinveenbirkenbachandClaude Opus 5 45a138d1a6 fix(mattermost): stop the session cookie defeating header auth in the specs
Mattermost classifies a request by the session cookie it carries, not by the
Authorization header, and enforces CSRF on non-GET requests for a
cookie-authenticated session.

test-litellm-agent.js took its token out of the MMAUTHTOKEN cookie on the
OIDC path, so every later context.request sent cookie and bearer together.
GET /users/me and the bot lookup returned 200 while POST /channels/direct
came back 401, and the spec died before it could prompt anything. Clear the
cookies once the token is in hand so only header auth remains.

mattermostLogout only navigated to /logout with waitUntil "commit" and
returned immediately, so the session survived and the spec still landed on
/main/channels/town-square afterwards. Perform a real server-side logout
first, passing the X-CSRF-Token from the MMCSRF cookie, then wait for the
session cookie to disappear. The wait removes the race only; the assertion
stays in the spec.

test-sso-login.js was already failing before any of this, so this repairs an
existing weakness rather than one introduced here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 12:11:50 +02:00

141 lines
5.6 KiB
JavaScript

const { expect } = require("@playwright/test");
const { resolveTimeout } = require("./timeouts");
const { decodeDotenvQuotedValue, gotoOnion } = require("./personas");
const { isServiceEnabled } = require("./service-gating");
const oidcEnabled = isServiceEnabled("sso");
const env = {
oidcIssuerUrl: decodeDotenvQuotedValue(process.env.OIDC_ISSUER_URL),
mattermostBaseUrl: decodeDotenvQuotedValue(process.env.MATTERMOST_BASE_URL),
adminUsername: decodeDotenvQuotedValue(process.env.ADMIN_USERNAME),
adminPassword: decodeDotenvQuotedValue(process.env.ADMIN_PASSWORD),
biberUsername: decodeDotenvQuotedValue(process.env.BIBER_USERNAME),
biberPassword: decodeDotenvQuotedValue(process.env.BIBER_PASSWORD),
};
function expectedOidcAuthUrl() {
return `${env.oidcIssuerUrl.replace(/\/$/, "")}/protocol/openid-connect/auth`;
}
function expectedMattermostBaseUrl() {
return env.mattermostBaseUrl.replace(/\/$/, "");
}
async function waitForFirstVisible(locators, timeout = resolveTimeout(60_000)) {
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
for (const locator of locators) {
if (await locator.first().isVisible().catch(() => false)) {
return locator.first();
}
}
await new Promise(r => setTimeout(r, resolveTimeout(500)));
}
throw new Error("Timed out waiting for one of the expected selectors to become visible");
}
async function startMattermostSsoFlow(page, baseUrl) {
const base = baseUrl.replace(/\/$/, "");
await page.addInitScript(() => {
try {
localStorage.setItem("__landingPageSeen__", "true");
} catch {}
});
let lastErr;
for (let attempt = 0; attempt < 2; attempt++) {
try {
await gotoOnion(page, `${base}/login`, { waitUntil: "load", timeout: resolveTimeout(60_000) });
if (page.url().includes("/landing")) {
await gotoOnion(page, `${base}/login`, { waitUntil: "load", timeout: resolveTimeout(60_000) });
}
await page.locator("#input_loginId").waitFor({ state: "visible", timeout: resolveTimeout(60_000) });
lastErr = undefined;
break;
} catch (e) {
lastErr = e;
}
}
if (lastErr) throw lastErr;
const ssoButton = page.locator("a[href='/oauth/gitlab/login']");
await ssoButton.waitFor({ state: "visible", timeout: resolveTimeout(30_000) });
await ssoButton.click({ timeout: resolveTimeout(30_000) });
}
async function dismissMattermostPopups(frame) {
const dismissSelectors = [
frame.getByRole("button", { name: /next|done|skip|got it|close|ok/i }),
frame.locator("[aria-label='Close'], .modal-header .close, button.close"),
frame.locator("[data-cy='onboarding-task-list-overlay']"),
frame.locator(".onboarding-tour-tip__close"),
frame.getByRole("button", { name: /no thanks/i }),
frame.getByText(/no thanks, i'?ll figure it out/i),
];
for (let round = 0; round < 3; round++) {
for (const sel of dismissSelectors) {
if (await sel.first().isVisible().catch(() => false)) {
await sel.first().click({ force: true }).catch(() => {});
await new Promise(r => setTimeout(r, resolveTimeout(500)));
}
}
await frame.evaluate(() => {
document.querySelectorAll("[data-cy='onboarding-task-list-overlay']").forEach(el => el.remove());
document.querySelectorAll("#root-portal").forEach(el => { el.style.display = "none"; });
}).catch(() => {});
await frame.locator("body").press("Escape").catch(() => {});
await new Promise(r => setTimeout(r, resolveTimeout(500)));
}
}
async function waitForMattermostChannelView(frame, timeout = resolveTimeout(60_000)) {
const channelSidebar = frame.locator(
".SidebarChannel, [data-testid='channel_sidebar'], #sidebar-left, .SidebarNavContainer"
);
const townSquare = frame.getByText("Town Square");
return waitForFirstVisible([channelSidebar, townSquare], timeout);
}
// `waitUntil: "commit"` avoids net::ERR_ABORTED from the multi-domain
// redirect chain the universal-logout service triggers.
async function mattermostLogout(page, baseUrl) {
const root = baseUrl.replace(/\/$/, "");
const session = await page.context().cookies(root).catch(() => []);
const csrf = session.find((cookie) => cookie.name === "MMCSRF");
await page.request
.post(`${root}/api/v4/users/logout`, {
headers: csrf ? { "X-CSRF-Token": csrf.value } : {},
failOnStatusCode: false,
})
.catch(() => {});
await gotoOnion(page, `${root}/logout`, { waitUntil: "commit" }).catch(() => {});
for (let attempt = 0; attempt < 40; attempt += 1) {
const cookies = await page.context().cookies(root).catch(() => []);
if (!cookies.some((cookie) => cookie.name === "MMAUTHTOKEN" && cookie.value)) return;
await page.waitForTimeout(resolveTimeout(500));
}
}
function beforeEach() {
expect(env.oidcIssuerUrl, "OIDC_ISSUER_URL must be set in the Playwright env file").toBeTruthy();
expect(env.mattermostBaseUrl, "MATTERMOST_BASE_URL must be set in the Playwright env file").toBeTruthy();
expect(env.adminUsername, "ADMIN_USERNAME must be set in the Playwright env file").toBeTruthy();
expect(env.adminPassword, "ADMIN_PASSWORD must be set in the Playwright env file").toBeTruthy();
expect(env.biberUsername, "BIBER_USERNAME must be set in the Playwright env file").toBeTruthy();
expect(env.biberPassword, "BIBER_PASSWORD must be set in the Playwright env file").toBeTruthy();
}
module.exports = {
env,
oidcEnabled,
expectedOidcAuthUrl,
expectedMattermostBaseUrl,
waitForFirstVisible,
startMattermostSsoFlow,
dismissMattermostPopups,
waitForMattermostChannelView,
mattermostLogout,
beforeEach,
};