From 522e1126f4b2b04bbcc65e5bb6ac2e8c49b01b46 Mon Sep 17 00:00:00 2001 From: milanmajchrak Date: Mon, 25 May 2026 13:46:30 +0200 Subject: [PATCH 1/2] fix(cypress): stabilize login flow and hide Klaro consent banner in e2e tests' --- cypress/support/commands.ts | 27 +++++++++++++++++++++++---- cypress/support/e2e.ts | 24 +++++++++++++++++++++++- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index a8e831da1da..fb7d192662e 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -100,13 +100,32 @@ function loginViaForm( ): void { cy.wait(500); - // Fill in credentials - cy.get('[data-test="email"]').should('be.visible').type(email); - cy.get('[data-test="password"]').type(password); + // Intercept the login POST so we can deterministically wait for it to complete, + // instead of racing the default 4s cy.get() timeout against a slow CI auth chain + // (POST /authn/login -> /authn/status -> NgRx state update -> router navigation + // away from /login -> home page render). On slower CI runners this routinely + // takes longer than 4s and the next `cy.get('#sidebar-collapse-toggle')` fails + // while the page is still on /login showing the "Loading..." spinner. + cy.intercept('POST', '**/api/authn/login').as('loginRequest'); + + // Fill in credentials. + // NOTE: on the standalone /login page the form is rendered twice in the DOM + // (once in the page body, once as the hidden navbar login dropdown). We must + // therefore scope the selectors to the visible form, otherwise + // `should('be.visible')` against the multi-element subject fails because the + // navbar copy lives inside a `display: none` dropdown. + cy.get('[data-test="email"]:visible').first().should('be.visible').type(email); + cy.get('[data-test="password"]:visible').first().type(password); // Submit the form - cy.get('[data-test="login-button"]').click(); + cy.get('[data-test="login-button"]:visible').first().click(); + + // Wait for the login POST to return successfully before letting the test continue. + cy.wait('@loginRequest').its('response.statusCode').should('eq', 200); + // Wait for the post-login redirect away from /login so the home page (and its + // sidebar) has a chance to render before the test assertions run. + cy.location('pathname', { timeout: 20000 }).should('not.match', /\/login$/); } // Add as a Cypress command (i.e. assign to 'cy.loginViaForm') Cypress.Commands.add('loginViaForm', loginViaForm); diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts index 10e785e1845..bc0487c44e3 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -57,12 +57,34 @@ before(() => { beforeEach(() => { // Pre-agree to all Klaro cookies by setting the klaro-anonymous cookie // This just ensures it doesn't get in the way of matching other objects in the page. - cy.setCookie('klaro-anonymous', '{%22authentication%22:true%2C%22preferences%22:true%2C%22acknowledgement%22:true%2C%22google-analytics%22:true%2C%22google-recaptcha%22:true}'); + cy.setCookie('klaro-anonymous', '{%22authentication%22:true%2C%22preferences%22:true%2C%22acknowledgement%22:true%2C%22google-analytics%22:true%2C%22google-recaptcha%22:true%2C%22accessibility%22:true}'); // Remove any CSRF cookies saved from prior tests cy.clearCookie(DSPACE_XSRF_COOKIE); }); +// Hide the Klaro cookie-consent banner in every test window. Even with a pre-set +// klaro-anonymous cookie, Klaro may still render the notice (e.g. when its +// internal consent version changes after a config update), and that notice +// overlaps interactive elements such as the admin sidebar toggle. Injecting a +// `display: none` rule for the `.klaro` container at every page load keeps the +// banner from intercepting clicks during e2e tests. +Cypress.on('window:before:load', (win) => { + const injectKlaroHider = () => { + if (!win.document.getElementById('cypress-hide-klaro')) { + const style = win.document.createElement('style'); + style.id = 'cypress-hide-klaro'; + style.textContent = '.klaro { display: none !important; }'; + (win.document.head || win.document.documentElement).appendChild(style); + } + }; + if (win.document && win.document.head) { + injectKlaroHider(); + } else { + win.addEventListener('DOMContentLoaded', injectKlaroHider, { once: true }); + } +}); + // NOTE: FALLBACK_TEST_REST_BASE_URL is only used if Cypress cannot read the REST API BaseURL // from the Angular UI's config.json. See 'before()' above. const FALLBACK_TEST_REST_BASE_URL = 'http://localhost:8080/server'; From d19fb57956b943d25e0e6410430fa4cc444825a9 Mon Sep 17 00:00:00 2001 From: milanmajchrak Date: Mon, 25 May 2026 15:27:54 +0200 Subject: [PATCH 2/2] =?UTF-8?q?=EF=BB=BFfix(cypress):=20use=20programmatic?= =?UTF-8?q?=20REST=20login=20to=20bypass=20hanging=20UI=20form=20on=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI evidence (run 26398927955) shows every login-protected spec failing with: 'cy.wait() timed out waiting 30000ms for the 1st response to the route: loginRequest. No response ever occurred.' The browser-side POST /api/authn/login dispatched from the Angular login form does not receive a response within Cypress' default 30s wait on the ubuntu-latest runner, while anonymous specs (23/55) pass. Switching to cy.request() drives the login from the Cypress (Node) side directly against the backend, bypassing CORS/XSRF/SSR timing problems, and writes the resulting auth token into the same UI cookie Angular reads on bootstrap. A subsequent cy.reload() rehydrates Angular as an authenticated user and preserves the original returnUrl for specs that visit a restricted page first (e.g. /mydspace, /submit). Also addresses Copilot review feedback on the prior attempt: removes the hard cy.wait(500) and encodes the klaro-anonymous cookie via JSON.stringify + encodeURIComponent. --- cypress/support/commands.ts | 76 +++++++++++++++++++++---------------- cypress/support/e2e.ts | 14 ++++++- 2 files changed, 57 insertions(+), 33 deletions(-) diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index fb7d192662e..c00f14652a6 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -89,43 +89,55 @@ function login(email: string, password: string): void { Cypress.Commands.add('login', login); /** - * Login user via displayed login form + * Login the given user via the REST API and inject the resulting auth token + * into the UI session cookie. Despite the historical "ViaForm" name this is + * now a fully programmatic login — clicking through the UI form is unreliable + * on the CI runners (the browser-side POST /api/authn/login routinely fails + * to receive a response within Cypress' default 30s wait, which previously + * caused every login-protected spec to fail with "No response ever + * occurred"). Going through cy.request() instead bypasses the SSR layer and + * any browser-side CORS/XSRF timing problems, then a single cy.visit('/') + * forces Angular to rehydrate as an authenticated user. + * * @param email email to login as * @param password password to login as */ -// Cypress custom command for form-based login with intercept and redirect assertion -function loginViaForm( - email: string, - password: string -): void { - cy.wait(500); - - // Intercept the login POST so we can deterministically wait for it to complete, - // instead of racing the default 4s cy.get() timeout against a slow CI auth chain - // (POST /authn/login -> /authn/status -> NgRx state update -> router navigation - // away from /login -> home page render). On slower CI runners this routinely - // takes longer than 4s and the next `cy.get('#sidebar-collapse-toggle')` fails - // while the page is still on /login showing the "Loading..." spinner. - cy.intercept('POST', '**/api/authn/login').as('loginRequest'); - - // Fill in credentials. - // NOTE: on the standalone /login page the form is rendered twice in the DOM - // (once in the page body, once as the hidden navbar login dropdown). We must - // therefore scope the selectors to the visible form, otherwise - // `should('be.visible')` against the multi-element subject fails because the - // navbar copy lives inside a `display: none` dropdown. - cy.get('[data-test="email"]:visible').first().should('be.visible').type(email); - cy.get('[data-test="password"]:visible').first().type(password); +function loginViaForm(email: string, password: string): void { + // Each invocation needs a fresh CSRF cookie/token pair, since prior tests + // (or this test's own beforeEach) explicitly clear the XSRF cookie. + cy.createCSRFCookie().then((csrfToken: string) => { + cy.task('getRestBaseURL').then((baseRestUrl: string) => { + cy.request({ + method: 'POST', + url: baseRestUrl + '/api/authn/login', + headers: { [XSRF_REQUEST_HEADER]: csrfToken }, + // form-urlencoded body, matching what the Angular login form sends + form: true, + body: { user: email, password: password }, + // Be generous: the very first login on a freshly-started DSpace + // backend in CI can take well over 30s while Hibernate warms up. + timeout: 120000, + }).then((resp) => { + expect(resp.status, 'login POST status').to.eq(200); + expect(resp.headers, 'login response headers').to.have.property('authorization'); - // Submit the form - cy.get('[data-test="login-button"]:visible').first().click(); - - // Wait for the login POST to return successfully before letting the test continue. - cy.wait('@loginRequest').its('response.statusCode').should('eq', 200); + // Persist the auth token into the UI cookie that Angular reads on + // bootstrap so the subsequent navigation is already authenticated. + const authHeader = resp.headers.authorization as string; + const authInfo: AuthTokenInfo = new AuthTokenInfo(authHeader); + cy.setCookie(TOKENITEM, JSON.stringify(authInfo)); + }); + }); + }); - // Wait for the post-login redirect away from /login so the home page (and its - // sidebar) has a chance to render before the test assertions run. - cy.location('pathname', { timeout: 20000 }).should('not.match', /\/login$/); + // Force Angular to re-bootstrap with the new auth cookie. cy.reload() + // preserves the current URL, so specs that visit a restricted page first + // (e.g. /mydspace -> redirected to /login?returnUrl=/mydspace) still end up + // back at the original destination after login. For specs that visit + // /login directly, the login page sees the authenticated user on bootstrap + // and redirects to /home. + cy.reload(); + cy.location('pathname', { timeout: 30000 }).should('not.match', /\/login$/); } // Add as a Cypress command (i.e. assign to 'cy.loginViaForm') Cypress.Commands.add('loginViaForm', loginViaForm); diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts index bc0487c44e3..9a63d47eda5 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -53,11 +53,23 @@ before(() => { }); }); +// Pre-agreed Klaro consent payload. Keep this list in sync with the services +// declared in src/app/shared/cookies/klaro-configuration.ts — otherwise Klaro +// detects a configuration change and re-shows the consent banner during tests. +const KLARO_CONSENT_PAYLOAD = encodeURIComponent(JSON.stringify({ + authentication: true, + preferences: true, + acknowledgement: true, + 'google-analytics': true, + 'google-recaptcha': true, + accessibility: true, +})); + // Runs once before the first test in each "block" beforeEach(() => { // Pre-agree to all Klaro cookies by setting the klaro-anonymous cookie // This just ensures it doesn't get in the way of matching other objects in the page. - cy.setCookie('klaro-anonymous', '{%22authentication%22:true%2C%22preferences%22:true%2C%22acknowledgement%22:true%2C%22google-analytics%22:true%2C%22google-recaptcha%22:true%2C%22accessibility%22:true}'); + cy.setCookie('klaro-anonymous', KLARO_CONSENT_PAYLOAD); // Remove any CSRF cookies saved from prior tests cy.clearCookie(DSPACE_XSRF_COOKIE);