Add generateViewEvent() util to generate stats in e2e tests. Refactored xsrf.interceptor.ts to move constants to a separate file so they can be reused in e2e tests easily.

This commit is contained in:
Tim Donohue
2023-03-06 16:13:43 -06:00
parent 48e95e262d
commit 371bd072b3
7 changed files with 144 additions and 41 deletions

View File

@@ -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 { testA11y } from 'cypress/support/utils';
import '../support/commands';
describe('Site Statistics Page', () => { describe('Site Statistics Page', () => {
it('should load if you click on "Statistics" from homepage', () => { it('should load if you click on "Statistics" from homepage', () => {
@@ -9,6 +10,10 @@ describe('Site Statistics Page', () => {
}); });
it('should pass accessibility tests', () => { 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'); cy.visit('/statistics');
// <ds-site-statistics-page> tag must be visable // <ds-site-statistics-page> tag must be visable

View File

@@ -4,10 +4,12 @@
// *********************************************** // ***********************************************
import { AuthTokenInfo, TOKENITEM } from 'src/app/core/auth/models/auth-token-info.model'; 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 // 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()'. // 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_BASE_URL = 'http://localhost:8080/server';
export const FALLBACK_TEST_REST_DOMAIN = 'localhost';
// Declare Cypress namespace to help with Intellisense & code completion in IDEs // Declare Cypress namespace to help with Intellisense & code completion in IDEs
// ALL custom commands MUST be listed here for code completion to work // ALL custom commands MUST be listed here for code completion to work
@@ -30,6 +32,15 @@ declare global {
* @param password password to login as * @param password password to login as
*/ */
loginViaForm(email: string, password: string): typeof loginViaForm; 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) { if (!config.rest.baseUrl) {
console.warn("Could not load 'rest.baseUrl' from config.json. Falling back to " + FALLBACK_TEST_REST_BASE_URL); console.warn("Could not load 'rest.baseUrl' from config.json. Falling back to " + FALLBACK_TEST_REST_BASE_URL);
} else { } 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; baseRestUrl = config.rest.baseUrl;
} }
// To login via REST, first we have to do a GET to obtain a valid CSRF token // Now find domain of our REST API, again with a fallback.
cy.request( baseRestUrl + '/api/authn/status' ) let baseDomain = FALLBACK_TEST_REST_DOMAIN;
.then((response) => { if (!config.rest.host) {
// We should receive a CSRF token returned in a response header console.warn("Could not load 'rest.host' from config.json. Falling back to " + FALLBACK_TEST_REST_DOMAIN);
expect(response.headers).to.have.property('dspace-xsrf-token'); } else {
const csrfToken = response.headers['dspace-xsrf-token']; baseDomain = config.rest.host;
}
// Now, send login POST request including that CSRF token // Create a fake CSRF Token. Set it in the required server-side cookie
cy.request({ const csrfToken = 'fakeLoginCSRFToken';
method: 'POST', cy.setCookie(DSPACE_XSRF_COOKIE, csrfToken, { 'domain': baseDomain });
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');
// Initialize our AuthTokenInfo object from the authorization header. // Now, send login POST request including that CSRF token
const authheader = resp.headers.authorization as string; cy.request({
const authinfo: AuthTokenInfo = new AuthTokenInfo(authheader); 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 // Initialize our AuthTokenInfo object from the authorization header.
// This ensures the UI will recognize we are logged in on next "visit()" const authheader = resp.headers.authorization as string;
cy.setCookie(TOKENITEM, JSON.stringify(authinfo)); 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') // Add as a Cypress command (i.e. assign to 'cy.login')
Cypress.Commands.add('login', login); Cypress.Commands.add('login', login);
/** /**
* Login user via displayed login form * Login user via displayed login form
* @param email email to login as * @param email email to login as
* @param password password 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 // Enter email
cy.get('ds-log-in [data-test="email"]').type(email); cy.get('ds-log-in [data-test="email"]').type(email);
// Enter password // Enter password
@@ -111,3 +127,60 @@ Cypress.Commands.add('login', login);
} }
// Add as a Cypress command (i.e. assign to 'cy.loginViaForm') // Add as a Cypress command (i.e. assign to 'cy.loginViaForm')
Cypress.Commands.add('loginViaForm', 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);

View File

@@ -8,7 +8,7 @@ import { PostRequest } from '../data/request.models';
import { import {
XSRF_REQUEST_HEADER, XSRF_REQUEST_HEADER,
XSRF_RESPONSE_HEADER XSRF_RESPONSE_HEADER
} from '../xsrf/xsrf.interceptor'; } from '../xsrf/xsrf.constants';
describe(`ServerAuthRequestService`, () => { describe(`ServerAuthRequestService`, () => {
let href: string; let href: string;

View File

@@ -13,7 +13,7 @@ import {
XSRF_REQUEST_HEADER, XSRF_REQUEST_HEADER,
XSRF_RESPONSE_HEADER, XSRF_RESPONSE_HEADER,
DSPACE_XSRF_COOKIE DSPACE_XSRF_COOKIE
} from '../xsrf/xsrf.interceptor'; } from '../xsrf/xsrf.constants';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';

View File

@@ -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';

View File

@@ -12,15 +12,7 @@ import { Observable, throwError } from 'rxjs';
import { tap, catchError } from 'rxjs/operators'; import { tap, catchError } from 'rxjs/operators';
import { RESTURLCombiner } from '../url-combiner/rest-url-combiner'; import { RESTURLCombiner } from '../url-combiner/rest-url-combiner';
import { CookieService } from '../services/cookie.service'; import { CookieService } from '../services/cookie.service';
import { XSRF_COOKIE, XSRF_REQUEST_HEADER, XSRF_RESPONSE_HEADER } from './xsrf.constants';
// 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';
/** /**
* Custom Http Interceptor intercepting Http Requests & Responses to * Custom Http Interceptor intercepting Http Requests & Responses to

View File

@@ -9,7 +9,7 @@ import { UploaderOptions } from './uploader-options.model';
import { hasValue, isNotEmpty, isUndefined } from '../../empty.util'; import { hasValue, isNotEmpty, isUndefined } from '../../empty.util';
import { UploaderProperties } from './uploader-properties.model'; import { UploaderProperties } from './uploader-properties.model';
import { HttpXsrfTokenExtractor } from '@angular/common/http'; 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 { CookieService } from '../../../core/services/cookie.service';
import { DragService } from '../../../core/drag.service'; import { DragService } from '../../../core/drag.service';