diff --git a/cypress/e2e/homepage-statistics.cy.ts b/cypress/e2e/homepage-statistics.cy.ts index 86b93d4259..2a1ab9785a 100644 --- a/cypress/e2e/homepage-statistics.cy.ts +++ b/cypress/e2e/homepage-statistics.cy.ts @@ -1,5 +1,6 @@ -import { REGEX_MATCH_NON_EMPTY_TEXT } from 'cypress/support/e2e'; +import { REGEX_MATCH_NON_EMPTY_TEXT, TEST_ENTITY_PUBLICATION } from 'cypress/support/e2e'; import { testA11y } from 'cypress/support/utils'; +import '../support/commands'; describe('Site Statistics Page', () => { it('should load if you click on "Statistics" from homepage', () => { @@ -9,6 +10,10 @@ describe('Site Statistics Page', () => { }); it('should pass accessibility tests', () => { + // generate 2 view events on an Item's page + cy.generateViewEvent(TEST_ENTITY_PUBLICATION, 'item'); + cy.generateViewEvent(TEST_ENTITY_PUBLICATION, 'item'); + cy.visit('/statistics'); // tag must be visable diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 75d235f056..2d3947d07f 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -4,10 +4,12 @@ // *********************************************** import { AuthTokenInfo, TOKENITEM } from 'src/app/core/auth/models/auth-token-info.model'; +import { DSPACE_XSRF_COOKIE, XSRF_REQUEST_HEADER } from 'src/app/core/xsrf/xsrf.constants'; // 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 'login()'. export const FALLBACK_TEST_REST_BASE_URL = 'http://localhost:8080/server'; +export const FALLBACK_TEST_REST_DOMAIN = 'localhost'; // Declare Cypress namespace to help with Intellisense & code completion in IDEs // ALL custom commands MUST be listed here for code completion to work @@ -30,6 +32,15 @@ declare global { * @param password password to login as */ loginViaForm(email: string, password: string): typeof loginViaForm; + + /** + * Generate view event for given object. Useful for testing statistics pages with + * pre-generated statistics. This just generates a single "hit", but can be called multiple times to + * generate multiple hits. + * @param uuid UUID of object + * @param dsoType type of DSpace Object (e.g. "item", "collection", "community") + */ + generateViewEvent(uuid: string, dsoType: string): typeof generateViewEvent; } } } @@ -56,52 +67,57 @@ function login(email: string, password: string): void { if (!config.rest.baseUrl) { console.warn("Could not load 'rest.baseUrl' from config.json. Falling back to " + FALLBACK_TEST_REST_BASE_URL); } else { - console.log("Found 'rest.baseUrl' in config.json. Using this REST API for login: ".concat(config.rest.baseUrl)); + //console.log("Found 'rest.baseUrl' in config.json. Using this REST API for login: ".concat(config.rest.baseUrl)); baseRestUrl = config.rest.baseUrl; } - // To login via REST, first we have to do a GET to obtain a valid CSRF token - cy.request( baseRestUrl + '/api/authn/status' ) - .then((response) => { - // We should receive a CSRF token returned in a response header - expect(response.headers).to.have.property('dspace-xsrf-token'); - const csrfToken = response.headers['dspace-xsrf-token']; + // Now find domain of our REST API, again with a fallback. + let baseDomain = FALLBACK_TEST_REST_DOMAIN; + if (!config.rest.host) { + console.warn("Could not load 'rest.host' from config.json. Falling back to " + FALLBACK_TEST_REST_DOMAIN); + } else { + baseDomain = config.rest.host; + } - // Now, send login POST request including that CSRF token - cy.request({ - method: 'POST', - url: baseRestUrl + '/api/authn/login', - headers: { 'X-XSRF-TOKEN' : csrfToken}, - form: true, // indicates the body should be form urlencoded - body: { user: email, password: password } - }).then((resp) => { - // We expect a successful login - expect(resp.status).to.eq(200); - // We expect to have a valid authorization header returned (with our auth token) - expect(resp.headers).to.have.property('authorization'); + // Create a fake CSRF Token. Set it in the required server-side cookie + const csrfToken = 'fakeLoginCSRFToken'; + cy.setCookie(DSPACE_XSRF_COOKIE, csrfToken, { 'domain': baseDomain }); - // Initialize our AuthTokenInfo object from the authorization header. - const authheader = resp.headers.authorization as string; - const authinfo: AuthTokenInfo = new AuthTokenInfo(authheader); + // Now, send login POST request including that CSRF token + cy.request({ + method: 'POST', + url: baseRestUrl + '/api/authn/login', + headers: { [XSRF_REQUEST_HEADER]: csrfToken}, + form: true, // indicates the body should be form urlencoded + body: { user: email, password: password } + }).then((resp) => { + // We expect a successful login + expect(resp.status).to.eq(200); + // We expect to have a valid authorization header returned (with our auth token) + expect(resp.headers).to.have.property('authorization'); - // Save our AuthTokenInfo object to our dsAuthInfo UI cookie - // This ensures the UI will recognize we are logged in on next "visit()" - cy.setCookie(TOKENITEM, JSON.stringify(authinfo)); - }); + // Initialize our AuthTokenInfo object from the authorization header. + const authheader = resp.headers.authorization as string; + const authinfo: AuthTokenInfo = new AuthTokenInfo(authheader); + + // Save our AuthTokenInfo object to our dsAuthInfo UI cookie + // This ensures the UI will recognize we are logged in on next "visit()" + cy.setCookie(TOKENITEM, JSON.stringify(authinfo)); }); + // Remove cookie with fake CSRF token, as it's no longer needed + cy.clearCookie(DSPACE_XSRF_COOKIE); }); } // Add as a Cypress command (i.e. assign to 'cy.login') Cypress.Commands.add('login', login); - /** * Login user via displayed login form * @param email email to login as * @param password password to login as */ - function loginViaForm(email: string, password: string): void { +function loginViaForm(email: string, password: string): void { // Enter email cy.get('ds-log-in [data-test="email"]').type(email); // Enter password @@ -111,3 +127,60 @@ Cypress.Commands.add('login', login); } // Add as a Cypress command (i.e. assign to 'cy.loginViaForm') Cypress.Commands.add('loginViaForm', loginViaForm); + + +/** + * Generate statistic view event for given object. Useful for testing statistics pages with + * pre-generated statistics. This just generates a single "hit", but can be called multiple times to + * generate multiple hits. + * @param uuid UUID of object + * @param dsoType type of DSpace Object (e.g. "item", "collection", "community") + */ +function generateViewEvent(uuid: string, dsoType: string): void { + // Cypress doesn't have access to the running application in Node.js. + // So, it's not possible to inject or load the AppConfig or environment of the Angular UI. + // Instead, we'll read our running application's config.json, which contains the configs & + // is regenerated at runtime each time the Angular UI application starts up. + cy.task('readUIConfig').then((str: string) => { + // Parse config into a JSON object + const config = JSON.parse(str); + + // Find the URL of our REST API. Have a fallback ready, just in case 'rest.baseUrl' cannot be found. + let baseRestUrl = FALLBACK_TEST_REST_BASE_URL; + if (!config.rest.baseUrl) { + console.warn("Could not load 'rest.baseUrl' from config.json. Falling back to " + FALLBACK_TEST_REST_BASE_URL); + } else { + baseRestUrl = config.rest.baseUrl; + } + + // Now find domain of our REST API, again with a fallback. + let baseDomain = FALLBACK_TEST_REST_DOMAIN; + if (!config.rest.host) { + console.warn("Could not load 'rest.host' from config.json. Falling back to " + FALLBACK_TEST_REST_DOMAIN); + } else { + baseDomain = config.rest.host; + } + + // Create a fake CSRF Token. Set it in the required server-side cookie + const csrfToken = 'fakeGenerateViewEventCSRFToken'; + cy.setCookie(DSPACE_XSRF_COOKIE, csrfToken, { 'domain': baseDomain }); + + // Now, send 'statistics/viewevents' POST request including that fake CSRF token in required header + cy.request({ + method: 'POST', + url: baseRestUrl + '/api/statistics/viewevents', + headers: { [XSRF_REQUEST_HEADER] : csrfToken}, + //form: true, // indicates the body should be form urlencoded + body: { targetId: uuid, targetType: dsoType } + }).then((resp) => { + // We expect a 201 (which means statistics event was created) + expect(resp.status).to.eq(201); + }); + + // Remove cookie with fake CSRF token, as it's no longer needed + cy.clearCookie(DSPACE_XSRF_COOKIE); + }); +} +// Add as a Cypress command (i.e. assign to 'cy.generateViewEvent') +Cypress.Commands.add('generateViewEvent', generateViewEvent); + diff --git a/src/app/core/auth/server-auth-request.service.spec.ts b/src/app/core/auth/server-auth-request.service.spec.ts index df6d78256b..5b0221e5df 100644 --- a/src/app/core/auth/server-auth-request.service.spec.ts +++ b/src/app/core/auth/server-auth-request.service.spec.ts @@ -8,7 +8,7 @@ import { PostRequest } from '../data/request.models'; import { XSRF_REQUEST_HEADER, XSRF_RESPONSE_HEADER -} from '../xsrf/xsrf.interceptor'; +} from '../xsrf/xsrf.constants'; describe(`ServerAuthRequestService`, () => { let href: string; diff --git a/src/app/core/auth/server-auth-request.service.ts b/src/app/core/auth/server-auth-request.service.ts index d6302081bc..058322acce 100644 --- a/src/app/core/auth/server-auth-request.service.ts +++ b/src/app/core/auth/server-auth-request.service.ts @@ -13,7 +13,7 @@ import { XSRF_REQUEST_HEADER, XSRF_RESPONSE_HEADER, DSPACE_XSRF_COOKIE -} from '../xsrf/xsrf.interceptor'; +} from '../xsrf/xsrf.constants'; import { map } from 'rxjs/operators'; import { Observable } from 'rxjs'; diff --git a/src/app/core/xsrf/xsrf.constants.ts b/src/app/core/xsrf/xsrf.constants.ts new file mode 100644 index 0000000000..64da5d674e --- /dev/null +++ b/src/app/core/xsrf/xsrf.constants.ts @@ -0,0 +1,33 @@ +/** + * XSRF / CSRF related constants + */ + +/** + * Name of CSRF/XSRF header we (client) may SEND in requests to backend. + * (This is a standard header name for XSRF/CSRF defined by Angular) + */ +export const XSRF_REQUEST_HEADER = 'X-XSRF-TOKEN'; + +/** + * Name of CSRF/XSRF header we (client) may RECEIVE in responses from backend + * This header is defined by DSpace backend, see https://github.com/DSpace/RestContract/blob/main/csrf-tokens.md + */ +export const XSRF_RESPONSE_HEADER = 'DSPACE-XSRF-TOKEN'; + +/** + * Name of client-side Cookie where we store the CSRF/XSRF token between requests. + * This cookie is only available to client, and should be updated whenever a new XSRF_RESPONSE_HEADER + * is found in a response from the backend. + */ +export const XSRF_COOKIE = 'XSRF-TOKEN'; + +/** + * Name of server-side cookie the backend expects the XSRF token to be in. + * When the backend receives a modifying request, it will validate the CSRF/XSRF token by looking + * for a match between the XSRF_REQUEST_HEADER and this Cookie. For more details see + * https://github.com/DSpace/RestContract/blob/main/csrf-tokens.md + * + * NOTE: This Cookie is NOT readable to the client/UI. It is only readable to the backend and will + * be sent along automatically by the user's browser. + */ +export const DSPACE_XSRF_COOKIE = 'DSPACE-XSRF-COOKIE'; diff --git a/src/app/core/xsrf/xsrf.interceptor.ts b/src/app/core/xsrf/xsrf.interceptor.ts index cded432397..c728a5cbd0 100644 --- a/src/app/core/xsrf/xsrf.interceptor.ts +++ b/src/app/core/xsrf/xsrf.interceptor.ts @@ -12,15 +12,7 @@ import { Observable, throwError } from 'rxjs'; import { tap, catchError } from 'rxjs/operators'; import { RESTURLCombiner } from '../url-combiner/rest-url-combiner'; import { CookieService } from '../services/cookie.service'; - -// Name of XSRF header we may send in requests to backend (this is a standard name defined by Angular) -export const XSRF_REQUEST_HEADER = 'X-XSRF-TOKEN'; -// Name of XSRF header we may receive in responses from backend -export const XSRF_RESPONSE_HEADER = 'DSPACE-XSRF-TOKEN'; -// Name of cookie where we store the XSRF token -export const XSRF_COOKIE = 'XSRF-TOKEN'; -// Name of cookie the backend expects the XSRF token to be in -export const DSPACE_XSRF_COOKIE = 'DSPACE-XSRF-COOKIE'; +import { XSRF_COOKIE, XSRF_REQUEST_HEADER, XSRF_RESPONSE_HEADER } from './xsrf.constants'; /** * Custom Http Interceptor intercepting Http Requests & Responses to diff --git a/src/app/shared/upload/uploader/uploader.component.ts b/src/app/shared/upload/uploader/uploader.component.ts index 14b1ca9b94..ef4ce4ee45 100644 --- a/src/app/shared/upload/uploader/uploader.component.ts +++ b/src/app/shared/upload/uploader/uploader.component.ts @@ -9,7 +9,7 @@ import { UploaderOptions } from './uploader-options.model'; import { hasValue, isNotEmpty, isUndefined } from '../../empty.util'; import { UploaderProperties } from './uploader-properties.model'; import { HttpXsrfTokenExtractor } from '@angular/common/http'; -import { XSRF_COOKIE, XSRF_REQUEST_HEADER, XSRF_RESPONSE_HEADER } from '../../../core/xsrf/xsrf.interceptor'; +import { XSRF_COOKIE, XSRF_REQUEST_HEADER, XSRF_RESPONSE_HEADER } from '../../../core/xsrf/xsrf.constants'; import { CookieService } from '../../../core/services/cookie.service'; import { DragService } from '../../../core/drag.service';