Skip to content
Snippets Groups Projects
Commit aaf51ed4 authored by Thong Kuah's avatar Thong Kuah
Browse files

Merge branch '20-add-session-prefix-cookie-match' into 'main'

Add session_prefix rules and implement matching cookie for classify

Closes #20

See merge request !233
parents 346b2dd8 28b8e703
No related branches found
No related tags found
1 merge request!233Add session_prefix rules and implement matching cookie for classify
Pipeline #1469606054 passed
......@@ -11,9 +11,14 @@ class RouterEngine {
this.env = env;
}
async fetch(request: Request, ctx: ExecutionContext): Promise<Response> {
const rule: Rule = this.rules[0]; // At the moment we always apply the first rule
return rule.apply(request, this.env, ctx);
async fetch(request: Request, ctx: ExecutionContext, logger: Logger): Promise<Response> {
for (const rule of this.rules) {
const matchResult = rule.match(request);
if (matchResult) return rule.apply(matchResult, request, this.env, ctx);
}
logger.info('No rules matched.');
throw new Error('No rules matched.');
}
}
......@@ -27,6 +32,6 @@ export default {
const rules = buildRules(env.GITLAB_RULES_CONFIG, logger);
const modifiedRequest = new Request(request.url, request);
const routingEngine = new RouterEngine(rules, env);
return routingEngine.fetch(modifiedRequest, ctx);
return routingEngine.fetch(modifiedRequest, ctx, logger);
},
} satisfies ExportedHandler<Env>;
import { Rule } from './rule';
import { ROUTER_RULE_TYPE_HEADER } from './constants';
import { ClassifyRuleInfo } from './types.d';
import { ClassifyRuleInfo, MatchResult } from './types.d';
import { classifyFetch } from '../topology_service/classify';
import { ClassifyResponse } from '../topology_service/types';
import { proxifyFetch } from '../utils';
......@@ -11,16 +11,25 @@ export class ClassifyRule extends Rule<ClassifyRuleInfo> {
request.headers.set(ROUTER_RULE_TYPE_HEADER, this.ruleInfo.classify.type);
}
override async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
override async fetch(matchResult: MatchResult, request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
if (!env.GITLAB_TOPOLOGY_SERVICE_URL) {
throw new Error('Topology Service is not configured to perform classify action');
}
const { type, value } = this.ruleInfo.classify;
const response = await classifyFetch(env, ctx, this.logger, type, value);
const interpolatedValue = this.interpolateValueWithMatchResult(matchResult, value);
const response = await classifyFetch(env, ctx, this.logger, type, interpolatedValue);
return this.handleClassifyResponse(request, response);
}
// This interpolates "${cell_name}" with the value in the capture group of
// /(?<cell_name>cell-\d+)/.
// `matchResult` can look like: `{ cell_name: 'cell-123' }` in this case.
private interpolateValueWithMatchResult(matchResult: MatchResult, value?: string): string | undefined {
if (value === undefined) return undefined;
else return value.replace(/\$\{(\w+)\}/g, (_, name) => matchResult[name] || '');
}
private handleClassifyResponse(request: Request, response: ClassifyResponse): Promise<Response> {
switch (response.action) {
case 'PROXY':
......
......@@ -4,10 +4,12 @@ import { ClassifyRule } from './classify';
import { ProxyRule } from './proxy';
import { Logger } from '../logger';
import session_prefix from './session_prefix.json';
import passthrough from './passthrough.json';
import firstcell from './firstcell.json';
const allRulesInfo: Map<string, RulesInfo> = new Map([
['session_prefix', session_prefix as RulesInfo],
['passthrough', passthrough as RulesInfo],
['firstcell', firstcell as RulesInfo],
]);
......
import { MatchInfo, MaybeMatchResult } from './types.d';
export function getCookieValue(name: string, request: Request): string | null {
const cookie = request.headers.get('Cookie') || '';
const values = cookie.match(new RegExp(`(?<=(^| )${name}=)[-_\\w]+`));
return values ? values[0] : null;
}
export function matchCookie(matchInfo: MatchInfo, request: Request): MaybeMatchResult {
const cookieValue = getCookieValue(matchInfo.regex_name, request);
if (cookieValue === null) {
return null;
} else {
const regexValue = new RegExp(matchInfo.regex_value);
const match = cookieValue.match(regexValue);
if (match) return match.groups || {};
else return null;
}
}
export function matchRequest(matchInfo: MatchInfo, request: Request): MaybeMatchResult {
switch (matchInfo.type) {
case 'cookie':
return matchCookie(matchInfo, request);
default:
throw new Error(`Unknown match type: ${matchInfo.type}`);
}
}
import { Rule } from './rule';
import { ProxyRuleInfo } from './types.d';
import { ProxyRuleInfo, MatchResult } from './types.d';
import { proxifyFetch } from '../utils';
export class ProxyRule extends Rule<ProxyRuleInfo> {
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- ctx is not used but it's part of the method signature
override async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- matchResult and ctx is not used but it's part of the method signature
override async fetch(matchResult: MatchResult, request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const proxyAddress = this.ruleInfo.proxy?.address || env.GITLAB_PROXY_HOST;
if (!proxyAddress) {
......
import { ROUTER_RULE_ACTION_HEADER } from './constants';
import { RuleInfo } from './types.d';
import { RuleInfo, MatchResult, MaybeMatchResult } from './types.d';
import { matchRequest } from './match_request';
import { Logger } from '../logger';
export abstract class Rule<T extends RuleInfo = RuleInfo> {
......@@ -8,15 +9,20 @@ export abstract class Rule<T extends RuleInfo = RuleInfo> {
protected logger: Logger,
) {}
async apply(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
match(request: Request): MaybeMatchResult {
if (this.ruleInfo.match) return matchRequest(this.ruleInfo.match, request);
else return {};
}
async apply(matchResult: MatchResult, request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
this.setHeaders(request);
this.logger.info({ ruleApplied: this.ruleInfo.id, ruleAction: this.ruleInfo.action });
return this.fetch(request, env, ctx);
return this.fetch(matchResult, request, env, ctx);
}
protected setHeaders(request: Request): void {
request.headers.set(ROUTER_RULE_ACTION_HEADER, this.ruleInfo.action);
}
protected abstract fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response>;
protected abstract fetch(matchResult: MatchResult, request: Request, env: Env, ctx: ExecutionContext): Promise<Response>;
}
[
{
"id": "2_session_prefix_force_cell",
"match": {
"type": "cookie",
"regex_name": "force-cell",
"regex_value": "^(?<cell_name>cell-\\d+)$"
},
"action": "classify",
"classify": {
"type": "SESSION_PREFIX",
"value": "${cell_name}"
}
},
{
"id": "1_session_prefix_gitlab_session",
"match": {
"type": "cookie",
"regex_name": "_gitlab_session(_[0-9a-f]+)?",
"regex_value": "^(?<cell_name>cell-\\d+)-.*"
},
"action": "classify",
"classify": {
"type": "SESSION_PREFIX",
"value": "${cell_name}"
}
},
{
"id": "0_session_prefix_proxy",
"action": "proxy"
}
]
......@@ -8,8 +8,15 @@ export enum ClassifyType {
FirstCell = 'FIRST_CELL',
}
interface MatchInfo {
type: string;
regex_name: string;
regex_value: string;
}
interface BaseRuleInfo {
id: string;
match?: MatchInfo;
}
interface ClassifyRuleInfo extends BaseRuleInfo {
......@@ -29,3 +36,5 @@ interface ProxyRuleInfo extends BaseRuleInfo {
export type RuleInfo = ClassifyRuleInfo | ProxyRuleInfo;
export type RulesInfo = RuleInfo[];
export type MatchResult = { [key: string]: string };
export type MaybeMatchResult = MatchResult | null;
......@@ -76,9 +76,3 @@ export async function proxifyFetch(request: Request, host: string, logger: Logge
return fetch(newRequest);
}
export function getCookieValue(name: string, request: Request): string | null {
const cookie = request.headers.get('Cookie') || '';
const values = cookie.match(new RegExp(`(?<=\\b${name}=)[-_\\w]+`));
return values ? values[0] : null;
}
......@@ -28,6 +28,45 @@ function withRulesConfig(value: string) {
});
}
function withProxyHost(host: string) {
beforeEach(() => {
env.GITLAB_PROXY_HOST = host;
});
afterEach(() => {
env.GITLAB_PROXY_HOST = null;
});
}
function withTopologyService(url: string) {
beforeEach(() => {
env.GITLAB_TOPOLOGY_SERVICE_URL = url;
});
afterEach(() => {
env.GITLAB_TOPOLOGY_SERVICE_URL = '';
});
}
function requestWithCookie(url: string, pairs: { [key: string]: string }): Request {
const request = new Request(url);
for (const key in pairs) {
request.headers.set('Cookie', `${key}=${pairs[key]}`);
}
return request;
}
async function workerFetchURL(url: string): Promise<Response> {
return workerFetchRequest(new Request(url));
}
async function workerFetchRequest(request: Request): Promise<Response> {
const ctx = createExecutionContext();
const response = await worker.fetch(request, env, ctx);
await waitOnExecutionContext(ctx);
return response;
}
describe('when rules are not configured', () => {
withRulesConfig('');
......@@ -65,13 +104,7 @@ describe('when rules are configured', () => {
withRulesConfig('passthrough');
describe('when proxy is configured to cell', () => {
beforeEach(() => {
env.GITLAB_PROXY_HOST = 'cell1.example.com';
});
afterEach(() => {
env.GITLAB_PROXY_HOST = null;
});
withProxyHost('cell1.example.com');
it('responds with cell1', async () => {
fetchMock
......@@ -79,10 +112,7 @@ describe('when rules are configured', () => {
.intercept({ path: '/', headers: { 'x-forwarded-host': 'example.com' } })
.reply(200, 'response from cell1');
const request = new Request('http://example.com');
const ctx = createExecutionContext();
const response = await worker.fetch(request, env, ctx);
await waitOnExecutionContext(ctx);
const response = await workerFetchURL('http://example.com');
expect(await response.text()).toBe('response from cell1');
});
......@@ -98,11 +128,7 @@ describe('when rules are configured', () => {
return 'response from cell1';
});
const request = new Request('http://example.com');
const ctx = createExecutionContext();
const response = await worker.fetch(request, env, ctx);
await waitOnExecutionContext(ctx);
const response = await workerFetchURL('http://example.com');
expect(await response.text()).toBe('response from cell1');
expect(headersSpy).toHaveBeenCalled();
......@@ -163,13 +189,7 @@ describe('when rules are configured', () => {
});
describe('when Topology Service is configured', () => {
beforeEach(() => {
env.GITLAB_TOPOLOGY_SERVICE_URL = 'http://topology-service.example.com';
});
afterEach(() => {
env.GITLAB_TOPOLOGY_SERVICE_URL = '';
});
withTopologyService('http://topology-service.example.com');
it('should fetch and cache response if not cached', async () => {
fetchMock
......@@ -190,11 +210,7 @@ describe('when rules are configured', () => {
const cachesDefaultMatchSpy = vi.spyOn(caches.default, 'match');
const fetchSpy = vi.spyOn(global, 'fetch');
const cachesDefaultPutSpy = vi.spyOn(caches.default, 'put');
const request = new Request('http://example.com');
const ctx = createExecutionContext();
const response = await worker.fetch(request, env, ctx);
await waitOnExecutionContext(ctx);
const response = await workerFetchURL('http://example.com');
expect(await response.text()).toBe('response from cell1');
expect(cachesDefaultMatchSpy).toHaveBeenCalledTimes(1);
......@@ -228,11 +244,7 @@ describe('when rules are configured', () => {
return 'response from cell1';
});
const request = new Request('http://example.com');
const ctx = createExecutionContext();
const response = await worker.fetch(request, env, ctx);
await waitOnExecutionContext(ctx);
const response = await workerFetchURL('http://example.com');
expect(await response.text()).toBe('response from cell1');
expect(headersSpy).toHaveBeenCalled();
......@@ -332,11 +344,7 @@ describe('when rules are configured', () => {
.reply(200, 'response from cell1');
const cachesDefaultPutSpy = vi.spyOn(caches.default, 'put');
const request = new Request('http://example.com');
const ctx = createExecutionContext();
const response = await worker.fetch(request, env, ctx);
await waitOnExecutionContext(ctx);
const response = await workerFetchURL('http://example.com');
expect(await response.text()).toBe('response from cell1');
expect(cachesDefaultMatchSpy).toHaveBeenCalledTimes(1);
......@@ -349,4 +357,155 @@ describe('when rules are configured', () => {
});
});
});
describe('with `session_prefix` rule', () => {
withRulesConfig('session_prefix');
describe('when force-cell cookie is matched', () => {
withTopologyService('http://ts.example.com');
describe('when value matched the format', () => {
it('sets correct headers', async () => {
const headersSpy = vi.fn();
fetchMock
.get('http://ts.example.com')
.intercept({
path: GITLAB_TOPOLOGY_SERVICE_CLASSIFY_PATH,
method: 'POST',
body: JSON.stringify({ type: ClassifyType.SessionPrefix, value: 'cell-123' }),
headers: { 'Content-Type': 'application/json' },
})
.reply(200, { action: 'PROXY', proxy: { address: 'cell-123.example.com' } });
fetchMock
.get('http://cell-123.example.com')
.intercept({ path: '/' })
.reply(200, (request) => {
headersSpy(request.headers);
return 'response from cell-123';
});
const request = requestWithCookie('http://example.com', { 'force-cell': 'cell-123' });
const response = await workerFetchRequest(request);
expect(await response.text()).toBe('response from cell-123');
expect(headersSpy).toHaveBeenCalled();
const capturedHeaders = headersSpy.mock.calls[0][0];
expect(capturedHeaders[ROUTER_RULE_ACTION_HEADER.toLowerCase()]).toBe(ActionType.Classify);
expect(capturedHeaders[ROUTER_RULE_TYPE_HEADER.toLowerCase()]).toBe(ClassifyType.SessionPrefix);
});
});
describe('when value does not match the format', () => {
withProxyHost('cell1.example.com');
it('responds using proxy rule', async () => {
fetchMock
.get('http://cell1.example.com')
.intercept({ path: '/', headers: { 'x-forwarded-host': 'example.com' } })
.reply(200, 'response from cell1');
const request = requestWithCookie('http://example.com', { '-force-cell': 'cell-123' });
const response = await workerFetchRequest(request);
expect(await response.text()).toBe('response from cell1');
});
});
});
describe('when session cookie matches the prefix', () => {
withTopologyService('http://ts3.example.com');
describe('when value matched the format', () => {
it('sets correct headers', async () => {
const headersSpy = vi.fn();
fetchMock
.get('http://ts3.example.com')
.intercept({
path: GITLAB_TOPOLOGY_SERVICE_CLASSIFY_PATH,
method: 'POST',
body: JSON.stringify({ type: ClassifyType.SessionPrefix, value: 'cell-135' }),
headers: { 'Content-Type': 'application/json' },
})
.reply(200, { action: 'PROXY', proxy: { address: 'cell-135.example.com' } });
fetchMock
.get('http://cell-135.example.com')
.intercept({ path: '/' })
.reply(200, (request) => {
headersSpy(request.headers);
return 'response from cell-135';
});
const request = requestWithCookie('http://example.com', { _gitlab_session: 'cell-135-random-gitlab-session-id' });
const response = await workerFetchRequest(request);
expect(await response.text()).toBe('response from cell-135');
expect(headersSpy).toHaveBeenCalled();
const capturedHeaders = headersSpy.mock.calls[0][0];
expect(capturedHeaders[ROUTER_RULE_ACTION_HEADER.toLowerCase()]).toBe(ActionType.Classify);
expect(capturedHeaders[ROUTER_RULE_TYPE_HEADER.toLowerCase()]).toBe(ClassifyType.SessionPrefix);
});
});
describe('when cookie name has postfix in the session cookie for local GDK', () => {
it('matches', async () => {
fetchMock
.get('http://ts3.example.com')
.intercept({
path: GITLAB_TOPOLOGY_SERVICE_CLASSIFY_PATH,
method: 'POST',
body: JSON.stringify({ type: ClassifyType.SessionPrefix, value: 'cell-246' }),
headers: { 'Content-Type': 'application/json' },
})
.reply(200, { action: 'PROXY', proxy: { address: 'cell-246.example.com' } });
fetchMock
.get('http://cell-246.example.com')
.intercept({ path: '/' })
.reply(200, () => {
return 'response from cell-246';
});
const request = requestWithCookie('http://example.com', {
_gitlab_session_ded842a2e07089230946000c0bb89783a57ba4c68fe7fb381b32b146978352b4: 'cell-246-random-gitlab-session-id',
});
const response = await workerFetchRequest(request);
expect(await response.text()).toBe('response from cell-246');
});
});
describe('when value does not match the format', () => {
withProxyHost('cell1.example.com');
it('responds using proxy rule', async () => {
fetchMock
.get('http://cell1.example.com')
.intercept({ path: '/', headers: { 'x-forwarded-host': 'example.com' } })
.reply(200, 'response from cell1');
const request = requestWithCookie('http://example.com', { _gitlab_session: 'random-gitlab-session-id' });
const response = await workerFetchRequest(request);
expect(await response.text()).toBe('response from cell1');
});
});
});
describe('when nothing matched', () => {
withProxyHost('cell1.example.com');
it('responds using proxy rule', async () => {
fetchMock
.get('http://cell1.example.com')
.intercept({ path: '/', headers: { 'x-forwarded-host': 'example.com' } })
.reply(200, 'response from cell1');
const response = await workerFetchURL('http://example.com');
expect(await response.text()).toBe('response from cell1');
});
});
});
});
import { describe, it, expect } from 'vitest';
import { ClassifyRule } from '../../src/rules/classify';
import { ProxyRule } from '../../src/rules/proxy';
import { buildRules } from '../../src/rules/index';
import { Logger } from '../../src/logger';
describe('buildRules', () => {
const logger: Logger = new Logger('debug');
describe('passthrough', () => {
it('returns a ProxyRule', async () => {
const rules = buildRules('passthrough', logger);
const passthrough = rules[0];
expect(rules.length).toBe(1);
expect(passthrough instanceof ProxyRule).toBe(true);
});
});
describe('firstcell', () => {
it('returns a ClassifyRule', async () => {
const rules = buildRules('firstcell', logger);
const passthrough = rules[0];
expect(rules.length).toBe(1);
expect(passthrough instanceof ClassifyRule).toBe(true);
});
});
describe('session_prefix', () => {
it('returns corresponding rules', async () => {
const rules = buildRules('session_prefix', logger);
const classifySession = rules[0];
const forceCell = rules[1];
const proxy = rules[2];
expect(rules.length).toBe(3);
expect(classifySession instanceof ClassifyRule).toBe(true);
expect(forceCell instanceof ClassifyRule).toBe(true);
expect(proxy instanceof ProxyRule).toBe(true);
});
});
});
import { describe, it, expect } from 'vitest';
import { MatchInfo } from '../../src/rules/types.d';
import { getCookieValue, matchCookie, matchRequest } from '../../src/rules/match_request';
function buildRequest(value: string): Request {
return new Request('', { headers: { Cookie: value } });
}
describe('getCookieValue', () => {
const name = '_gitlab_session';
describe('with single cookie value with dashes', () => {
it('returns the value', async () => {
const expected = 'cells-1-fake-session-id';
const value = getCookieValue(name, buildRequest(`${name}=${expected}`));
expect(value).toBe(expected);
});
});
describe('with multiple cookies value with underscores', () => {
it('returns the values we are looking for', async () => {
const expected = 'cells-1-fake_session_id';
const value = getCookieValue(name, buildRequest(`first=one; ${name}=${expected}`));
expect(value).toBe(expected);
});
});
describe('with multiple cookies value and target at first', () => {
it('returns the values we are looking for', async () => {
const expected = 'cells-1-fake-session-id';
const value = getCookieValue(name, buildRequest(`${name}=${expected}; last=two`));
expect(value).toBe(expected);
});
});
describe('with multiple cookies with similar starting names', () => {
it('returns the values we are looking for', async () => {
const expected = 'cells-1-fake-session-id';
const value = getCookieValue(name, buildRequest(`${name}-bad=bad; ${name}_bad=bad; ${name}=${expected}`));
expect(value).toBe(expected);
});
});
describe('with multiple cookies with similar ending names', () => {
it('returns the values we are looking for', async () => {
const expected = 'cells-1-fake-session-id';
const value = getCookieValue(name, buildRequest(`bad-${name}=bad; bad_${name}=bad; ${name}=${expected}`));
expect(value).toBe(expected);
});
});
describe('when there is no matching name', () => {
it('returns null', async () => {
const expected = null;
const value = getCookieValue(name, buildRequest(`${name}_=${expected}`));
expect(value).toBe(expected);
});
});
});
describe('matchCookie', () => {
const name = '_gitlab_session';
function buildMatchInfo(regex_value: string): MatchInfo {
return { type: 'cookie', regex_name: name, regex_value: regex_value };
}
it('returns the capture groups', async () => {
const expected = { cell_name: 'cell-4' };
const result = matchCookie(buildMatchInfo('^(?<cell_name>cell-\\d+)-.*'), buildRequest(`${name}=cell-4-fake_session_id`));
expect(result).toEqual(expected);
});
describe('when it does not match', () => {
it('returns null', async () => {
const expected = null;
const result = matchCookie(buildMatchInfo('^(?<cell_name>cell-\\d+)-.*'), buildRequest(`${name}=bell-4-fake_session_id`));
expect(result).toBe(expected);
});
});
describe('when it matches but there is no capture group', () => {
it('returns {}', async () => {
const expected = {};
const result = matchCookie(buildMatchInfo('^cell-\\d+-.*'), buildRequest(`${name}=cell-4-fake_session_id`));
expect(result).toEqual(expected);
});
});
});
describe('matchRequest', () => {
const name = '_gitlab_session';
const value = 'cookie-value';
function buildMatchInfo(type: string): MatchInfo {
return { type: type, regex_name: name, regex_value: value };
}
it('uses matchCookie when type is cookie', async () => {
const expected = {};
const result = matchRequest(buildMatchInfo('cookie'), buildRequest(`${name}=${value}`));
expect(result).toEqual(expected);
});
});
import { createExecutionContext, waitOnExecutionContext } from 'cloudflare:test';
import { beforeAll, beforeEach, describe, it, expect, vi } from 'vitest';
import { secureCachedApiFetch, proxifyFetch, getCookieValue } from '../src/utils';
import { secureCachedApiFetch, proxifyFetch } from '../src/utils';
import { Logger } from '../src/logger';
describe('secureCachedApiFetch', () => {
......@@ -96,65 +96,3 @@ describe('proxifyFetch', () => {
expect(response).toBe(mockResponse);
});
});
describe('getCookieValue', () => {
const name = '_gitlab_session';
function buildRequest(value: string): Request {
return new Request('', { headers: { Cookie: value } });
}
describe('with single cookie value with dashes', () => {
it('returns the value', async () => {
const expected = 'cells-1-fake-session-id';
const value = getCookieValue(name, buildRequest(`${name}=${expected}`));
expect(value).toBe(expected);
});
});
describe('with multiple cookies value with underscores', () => {
it('returns the values we are looking for', async () => {
const expected = 'cells-1-fake_session_id';
const value = getCookieValue(name, buildRequest(`first=one; ${name}=${expected}`));
expect(value).toBe(expected);
});
});
describe('with multiple cookies value and target at first', () => {
it('returns the values we are looking for', async () => {
const expected = 'cells-1-fake-session-id';
const value = getCookieValue(name, buildRequest(`${name}=${expected}; last=two`));
expect(value).toBe(expected);
});
});
describe('with multiple cookies with similar starting names', () => {
it('returns the values we are looking for', async () => {
const expected = 'cells-1-fake-session-id';
const value = getCookieValue(name, buildRequest(`${name}_bad=bad; ${name}=${expected}`));
expect(value).toBe(expected);
});
});
describe('with multiple cookies with similar ending names', () => {
it('returns the values we are looking for', async () => {
const expected = 'cells-1-fake-session-id';
const value = getCookieValue(name, buildRequest(`bad${name}=bad; ${name}=${expected}`));
expect(value).toBe(expected);
});
});
describe('when there is no matching name', () => {
it('returns null', async () => {
const expected = null;
const value = getCookieValue(name, buildRequest(`${name}_=${expected}`));
expect(value).toBe(expected);
});
});
});
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment