Skip to content

Commit

Permalink
Merge pull request #1209 from City-of-Helsinki/hds-2065-change-cookie…
Browse files Browse the repository at this point in the history
…-domain-cleaned

HDS-2065: change default cookie domain
  • Loading branch information
NikoHelle authored Apr 4, 2024
2 parents 5a921f0 + a91a799 commit 2d4841d
Show file tree
Hide file tree
Showing 15 changed files with 664 additions and 85 deletions.
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

#### Added

- [Component] What is added?
- [CookieConsent] A new cookie is set containing version number of consents.

#### Changed

Changes that are not related to specific components

- [Component] What has been changed
- [Link] Possibility to style a Link as button with `useButtonStyles` prop.
- [CookieConsent] Consent cookie's default domain was changed to window.location.hostname.

#### Fixed

Expand All @@ -40,6 +42,7 @@ Changes that are not related to specific components
#### Changed

Changes that are not related to specific components

- [Component] What has been changed
- [Link] Possibility to style a Link as button.

Expand Down Expand Up @@ -80,6 +83,7 @@ Changes that are not related to specific components
#### Changed

Changes that are not related to specific components

- [Component] What has been changed

#### Fixed
Expand All @@ -99,6 +103,7 @@ Changes that are not related to specific components
#### Changed

Changes that are not related to specific components

- [Component] What has been changed

#### Fixed
Expand All @@ -118,6 +123,7 @@ Changes that are not related to specific components
#### Changed

Changes that are not related to specific components

- [Component] What has been changed

#### Fixed
Expand All @@ -137,6 +143,7 @@ Changes that are not related to specific components
#### Changed

Changes that are not related to specific components

- [Component] What has been changed

#### Fixed
Expand Down
Original file line number Diff line number Diff line change
@@ -1,54 +1,104 @@
import cookie from 'cookie';
import cookie, { CookieSerializeOptions } from 'cookie';

type Options = Record<string, string | boolean | Date | number>;

export type MockedDocumentCookieActions = {
init: (keyValuePairs: Record<string, string>) => void;
add: (keyValuePairs: Record<string, string>) => void;
add: (keyValuePairs: Record<string, string>, commonOptions?: CookieSerializeOptions) => void;
createCookieData: (keyValuePairs: Record<string, string>) => string;
getCookie: () => string;
getCookieOptions: (key: string) => Options;
getCookieOptions: (key: string, storedOptions?: CookieSerializeOptions, getDeleted?: boolean) => Options;
extractCookieOptions: (cookieStr: string, keyToRemove: string) => Options;
getSerializeOptions: () => CookieSerializeOptions;
restore: () => void;
clear: () => void;
mockGet: jest.Mock;
mockSet: jest.Mock;
};

export default function mockDocumentCookie(): MockedDocumentCookieActions {
export default function mockDocumentCookie(
serializeOptionsOverride: CookieSerializeOptions = {},
): MockedDocumentCookieActions {
const COOKIE_OPTIONS_DELIMETER = ';';
const globalDocument = global.document;
const oldDocumentCookie = globalDocument.cookie;
const current = new Map<string, string>();
const cookieWithOptions = new Map<string, string | undefined>();
const deletedActionSuffix = 'deleted!';
const keyDelimeter = '___';
const actionDelimeter = '>>>';
const pathAndDomainDelimeter = '###';

const getter = jest.fn((): string => {
return Array.from(current.entries())
.map(([k, v]) => `${k} = ${v}${COOKIE_OPTIONS_DELIMETER}`)
.join(' ');
});
const serializeOptions: CookieSerializeOptions = {
domain: 'default.domain.com',
path: '/',
...serializeOptionsOverride,
};

const setter = jest.fn((cookieData: string): void => {
const parseKeyValuePair = (cookieData: string): [string, string] => {
const [key, value] = cookieData.split('=');
const trimmedKey = key.trim();
if (!trimmedKey) {
return;
return ['', ''];
}
const newValue = String(value.split(COOKIE_OPTIONS_DELIMETER)[0]).trim();
current.set(trimmedKey, newValue);
cookieWithOptions.set(trimmedKey, cookieData);
});
return [trimmedKey, newValue];
};

Reflect.deleteProperty(globalDocument, 'cookie');
Reflect.defineProperty(globalDocument, 'cookie', {
configurable: true,
get: () => getter(),
set: (newValue) => setter(newValue),
});
const createKeyWithAllData = (key: string, path: string, domain: string, action?: string) => {
const pathAndDomain = `${encodeURIComponent(domain)}${pathAndDomainDelimeter}${encodeURIComponent(path)}`;
return `${key}${keyDelimeter}${pathAndDomain}${actionDelimeter}${action}`;
};

const setWithObject = (keyValuePairs: Record<string, string>) =>
Object.entries(keyValuePairs).forEach(([k, v]) => {
setter(`${k}=${v}`);
});
const splitKeyWithPathAndDomain = (source: string) => {
const [key, pathAndDomainAction] = source.split(keyDelimeter);
const [pathAndDomain, action] = String(pathAndDomainAction).split(actionDelimeter);
const [encodedDomain, encodedPath] = String(pathAndDomain).split(pathAndDomainDelimeter);
return {
key,
path: decodeURIComponent(encodedPath),
domain: decodeURIComponent(encodedDomain),
action,
};
};

const createDeleteKey = (key: string, serializedData: string) => {
// eslint-disable-next-line @typescript-eslint/no-use-before-define
const { path, domain } = getDomainAndPath(serializedData);
return createKeyWithAllData(key, path, domain, deletedActionSuffix);
};

const createStoredKey = (key: string, serializedData: string) => {
// eslint-disable-next-line @typescript-eslint/no-use-before-define
const { path, domain } = getDomainAndPath(serializedData);
return createKeyWithAllData(key, path, domain);
};

const storedKeyToDeleteKey = (keyWitProps: string) => {
const { path, domain, key } = splitKeyWithPathAndDomain(keyWitProps);

return createKeyWithAllData(key, path, domain, deletedActionSuffix);
};

const getOriginalKey = (key: string) => {
return key.split(keyDelimeter)[0];
};

const hasDeleteMatch = (keyWithAllProps: string) => {
return current.has(storedKeyToDeleteKey(keyWithAllProps));
};

const isDeletedCookie = (key: string) => {
return key.includes(deletedActionSuffix) || hasDeleteMatch(key);
};

const createData = (data: [string, string][]): string => {
return data
.filter(([k]) => !isDeletedCookie(k))
.map(([k, v]) => `${getOriginalKey(k)}=${v}${COOKIE_OPTIONS_DELIMETER}`)
.join(' ')
.slice(0, -1); // cookies do not have the last ";"
};

const extractCookieOptions = (cookieStr: string, keyToRemove: string): Options => {
const fullCookie = cookie.parse(cookieStr);
Expand Down Expand Up @@ -84,13 +134,90 @@ export default function mockDocumentCookie(): MockedDocumentCookieActions {
}, options) as Options;
};

const hasDeleteFormat = (cookieData: string) => {
const { maxAge, expires } = extractCookieOptions(cookieData, '') as { maxAge: number; expires: Date };

if (!Number.isNaN(maxAge) && maxAge < 1) {
return true;
}
const time = expires && expires.getTime();
if (!Number.isNaN(time) && time < Date.now()) {
return true;
}
return false;
};

const getDomainAndPath = (serializedData: string): { path: string; domain: string } => {
const { path = '', domain = '' } = { ...serializeOptions, ...extractCookieOptions(serializedData, '') };
return { path, domain };
};

const getter = jest.fn((): string => {
return createData(Array.from(current.entries()));
});

const setter = jest.fn((cookieData: string): void => {
const [trimmedKey, newValue] = parseKeyValuePair(cookieData);
if (!trimmedKey) {
return;
}
if (hasDeleteFormat(cookieData)) {
const deleteKey = createDeleteKey(trimmedKey, cookieData);
current.set(deleteKey, newValue);
cookieWithOptions.set(deleteKey, cookieData);
return;
}

const keyWithPathAndDomain = createStoredKey(trimmedKey, cookieData);

if (hasDeleteMatch(keyWithPathAndDomain)) {
const deleteKeyToDelete = createDeleteKey(trimmedKey, cookieData);
current.delete(deleteKeyToDelete);
// Note: cookieWithOptions are not deleted!
// This way it can be veried a cookie has been deleted at least once
}
current.set(keyWithPathAndDomain, newValue);
cookieWithOptions.set(keyWithPathAndDomain, cookieData);
});

Reflect.deleteProperty(globalDocument, 'cookie');
Reflect.defineProperty(globalDocument, 'cookie', {
configurable: true,
get: () => getter(),
set: (newValue) => setter(newValue),
});

const setWithObject = (
keyValuePairs: Record<string, string>,
commonOptions: CookieSerializeOptions = serializeOptions,
) =>
Object.entries(keyValuePairs).forEach(([k, v]) => {
setter(cookie.serialize(k, v, commonOptions));
});

return {
add: (keyValuePairs) => setWithObject(keyValuePairs),
add: (keyValuePairs, commonOptions) => setWithObject(keyValuePairs, commonOptions),
createCookieData: (keyValuePairs) => {
const data = Object.entries(keyValuePairs).map(([k, v]) => {
return parseKeyValuePair(`${k}=${v}`);
});
return createData(data);
},
getCookie: () => {
return getter();
},
getCookieOptions: (key) => {
return extractCookieOptions(cookieWithOptions.get(key), key);
getCookieOptions: (key, storedOptions = serializeOptions, getDeleted = false) => {
const storedKey = createKeyWithAllData(
key,
String(storedOptions.path),
String(storedOptions.domain),
getDeleted ? deletedActionSuffix : undefined,
);
const options = cookieWithOptions.get(storedKey) as string;
if (!options) {
throw new Error(`No options found for ${key}/${storedKey}`);
}
return extractCookieOptions(options, key);
},
extractCookieOptions,
restore: () => {
Expand All @@ -106,6 +233,8 @@ export default function mockDocumentCookie(): MockedDocumentCookieActions {
setWithObject(keyValuePairs);
setter.mockClear();
},
getSerializeOptions: () => serializeOptions,

mockGet: getter,
mockSet: setter,
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
/* eslint-disable jest/no-mocks-import */
import { get } from 'lodash';

import { CookieContentSource, ContentSourceCookieGroup, createContent } from './content.builder';
import { getCookieContent } from './getContent';
import { CookieData, CookieGroup, Content, Category } from './contexts/ContentContext';
import { COOKIE_NAME } from './cookieConsentController';
import mockWindowLocation from '../../utils/mockWindowLocation';
import { VERSION_COOKIE_NAME } from './cookieStorageProxy';

describe(`content.builder.ts`, () => {
const mockedWindowControls = mockWindowLocation();
const commonContent = getCookieContent();
const siteName = 'hel.fi';
const commonContentTestProps: CookieContentSource = {
Expand Down Expand Up @@ -122,6 +127,10 @@ describe(`content.builder.ts`, () => {
return JSON.parse(JSON.stringify(content));
};

afterAll(() => {
mockedWindowControls.restore();
});

describe('createContent', () => {
it('returns content.texts and content.language when categories are not passed. SiteName is in main.title.', () => {
const plainContent = createContent(commonContentTestProps);
Expand Down Expand Up @@ -883,14 +892,50 @@ describe(`content.builder.ts`, () => {
expect(filterContentWithoutFunctions(contentWithCookie)).toEqual(filterContentWithoutFunctions(expectedResult));
});
});
describe('Automatically adds the consent storage cookie to required consents', () => {
describe('Automatically adds the consent and consent version cookies to required consents', () => {
const baseProps = { siteName, currentLanguage: 'fi' } as CookieContentSource;
const pickSharedConsentGroup = (source: Content) => {
return source.requiredCookies?.groups[0] as CookieGroup;
};
const findSharedConsentCookie = (source: Content, id: CookieData['id']) => {
return pickSharedConsentGroup(source).cookies.filter((cookie) => cookie.id === id)[0] as CookieData;
};
const pickSharedConsentCookie = (source: Content) => {
return findSharedConsentCookie(source, COOKIE_NAME);
};
const pickSharedConsentVersionCookie = (source: Content) => {
return findSharedConsentCookie(source, VERSION_COOKIE_NAME);
};
it('when noCommonConsentCookie is not true', () => {
const content = createContent({ siteName, currentLanguage: 'fi' });
const content = createContent(baseProps);
const group = pickSharedConsentGroup(content);
expect(content.requiredCookies).toBeDefined();
expect(content.requiredCookies?.groups[0].title).toBe(commonContent.commonGroups.sharedConsents.fi.title);
expect(content.requiredCookies?.groups[0].cookies[0].name).toBe(
commonContent.commonCookies.helConsentCookie.fi.name,
);
expect(group.title).toBe(commonContent.commonGroups.sharedConsents.fi.title);
expect(pickSharedConsentCookie(content)).toBeDefined();
expect(pickSharedConsentVersionCookie(content)).toBeDefined();
});
it('cookies have name, hostName and id set automatically', () => {
const windowHostName = 'subdomain.hel.fi';
const customHostName = 'cookie.domain.com';
mockedWindowControls.setUrl(`https://${windowHostName}`);

const content = createContent(baseProps, customHostName);
const storageCookie = pickSharedConsentCookie(content);

expect(storageCookie.id).toBe(COOKIE_NAME);
expect(storageCookie.name).toBe(COOKIE_NAME);
expect(storageCookie.hostName).toBe(customHostName);

const versionCookie = pickSharedConsentVersionCookie(content);
expect(versionCookie.id).toBe(VERSION_COOKIE_NAME);
expect(versionCookie.name).toBe(VERSION_COOKIE_NAME);
expect(versionCookie.hostName).toBe(customHostName);

const contentWithoutPresetDomain = createContent(baseProps);
const storageCookie2 = pickSharedConsentCookie(contentWithoutPresetDomain);
expect(storageCookie2.hostName).toBe(windowHostName);
const versionCookie2 = pickSharedConsentVersionCookie(contentWithoutPresetDomain);
expect(versionCookie2.hostName).toBe(windowHostName);
});
});
});
Loading

0 comments on commit 2d4841d

Please sign in to comment.