Skip to content

Commit

Permalink
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: better header checking
Browse files Browse the repository at this point in the history
kanadgupta committed Sep 5, 2023
1 parent 8cfffb0 commit 9d4749f
Showing 5 changed files with 99 additions and 39 deletions.
61 changes: 53 additions & 8 deletions __tests__/helpers/get-api-mock.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import type { Headers } from 'headers-polyfill';
import type { ResponseTransformer } from 'msw';

import config from 'config';
import { rest } from 'msw';
import nock from 'nock';

import { getUserAgent } from '../../src/lib/readmeAPIFetch';

/**
* A type describing a raw object of request headers.
* We use this in our API request mocking to validate that the request
* contains all the expected headers.
*/
type ReqHeaders = Record<string, unknown>;

/**
* Nock wrapper that adds required `user-agent` request header
* so it gets properly picked up by nock.
@@ -26,23 +34,60 @@ export function getAPIMockWithVersionHeader(v: string) {
});
}

// TODO: add ability to check for other headers
function doHeadersMatch(headers: Headers) {
function validateHeaders(headers: Headers, basicAuthUser: string, expectedReqHeaders: ReqHeaders) {
// validate all headers in expectedReqHeaders
Object.keys(expectedReqHeaders).forEach(reqHeaderKey => {
if (headers.get(reqHeaderKey) !== expectedReqHeaders[reqHeaderKey]) {
throw new Error(
`Expected the request header '${expectedReqHeaders[reqHeaderKey]}', received '${headers.get(reqHeaderKey)}'`,
);
}
});

// validate basic auth header
if (basicAuthUser) {
const encodedApiKey = headers.get('Authorization').split(' ')[1];
const decodedApiKey = Buffer.from(encodedApiKey, 'base64').toString();
if (decodedApiKey !== `${basicAuthUser}:`) {
throw new Error(`Expected API key '${basicAuthUser}', received '${decodedApiKey}'`);
}
}

const userAgent = headers.get('user-agent');
return userAgent === getUserAgent();
if (userAgent !== getUserAgent()) {
throw new Error(`Expected user agent '${getUserAgent()}', received '${userAgent}'`);
}
}

export function getAPIMockMSW(
/**
* API route to mock against, must start with slash
* @example /api/v1
*/
path: string = '',
method: keyof typeof rest = 'get',
status = 200,
response?: { json?: unknown; text?: string },
/**
* A string which represents the user that's passed via basic authentication.
* In our case, this will almost always be the user's ReadMe API key.
*/
basicAuthUser = '',
/** Any request headers that should be matched. */
expectedReqHeaders: ReqHeaders = {},
proxy = '',
) {
return rest[method](`${proxy}${config.get('host')}${path}`, (req, res, ctx) => {
if (doHeadersMatch(req.headers)) {
return res(ctx.status(status), ctx.json(response.json));
return rest.get(`${proxy}${config.get('host')}${path}`, (req, res, ctx) => {
try {
validateHeaders(req.headers, basicAuthUser, expectedReqHeaders);
let responseTransformer: ResponseTransformer;
if (response.json) {
responseTransformer = ctx.json(response.json);
} else if (response.text) {
responseTransformer = ctx.text(response.text);
}
return res(ctx.status(status), responseTransformer);
} catch (e) {
throw new Error(`Error mocking GET request to https://dash.readme.com${path}: ${e.message}`);
}
return res(ctx.status(500), ctx.json({ error: 'MSW error' }));
});
}
29 changes: 26 additions & 3 deletions __tests__/helpers/vitest.matchers.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { ExpectationResult } from '@vitest/expect';
import type { AnySchema } from 'ajv';

import betterAjvErrors from '@readme/better-ajv-errors';
@@ -6,6 +7,10 @@ import jsYaml from 'js-yaml';
import { expect } from 'vitest';

interface CustomMatchers<R = unknown> {
/**
* Ensures that the decoded Basic Auth header matches the expected API key.
*/
toBeBasicAuthApiKey(expectedApiKey: string): R;
/**
* Ensures that the expected YAML conforms to the given JSON Schema.
*/
@@ -18,12 +23,30 @@ declare module 'vitest' {
interface AsymmetricMatchersContaining extends CustomMatchers {}
}

export default function toBeValidSchema(
function toBeBasicAuthApiKey(actualAuthorizationHeader: string, expectedApiKey: string): ExpectationResult {
const encodedApiKey = actualAuthorizationHeader.split(' ')[1];
const decodedApiKey = Buffer.from(encodedApiKey, 'base64').toString().replace(/:$/, '');
if (decodedApiKey !== expectedApiKey) {
return {
message: () => 'expected Basic Auth header to match API key',
pass: false,
actual: decodedApiKey,
expected: expectedApiKey,
};
}

return {
message: () => 'expected Basic Auth header to not match API key',
pass: true,
};
}

function toBeValidSchema(
/** The input YAML, as a string */
yaml: string,
/** The JSON schema file */
schema: AnySchema,
): { message: () => string; pass: boolean } {
): ExpectationResult {
const ajv = new Ajv({ strictTypes: false, strictTuples: false });

const data = jsYaml.load(yaml);
@@ -49,4 +72,4 @@ export default function toBeValidSchema(
};
}

expect.extend({ toBeValidSchema });
expect.extend({ toBeBasicAuthApiKey, toBeValidSchema });
46 changes: 18 additions & 28 deletions __tests__/single-threaded/openapi/index.test.ts
Original file line number Diff line number Diff line change
@@ -67,24 +67,22 @@ describe('rdme openapi (single-threaded)', () => {

describe('upload', () => {
it('should discover and upload an API definition if none is provided', async () => {
expect.assertions(5);
expect.assertions(6);
const registryUUID = getRandomRegistryId();

server.use(
...[
// TODO: basic auth
getAPIMockMSW(`/api/v1/version/${version}`, 'get', 200, { json: { version } }),
getAPIMockMSW(`/api/v1/version/${version}`, 200, { json: { version } }, key),
rest.post(`${config.get('host')}/api/v1/api-registry`, async (req, res, ctx) => {
const body = await req.text();
expect(body).toMatch('form-data; name="spec"');
return res(ctx.status(201), ctx.json({ registryUUID, spec: { openapi: '3.0.0' } }));
}),
// TODO: basic auth and version header
getAPIMockMSW('/api/v1/api-specification', 'get', 200, { json: [] }),
// TODO: basic auth
getAPIMockMSW('/api/v1/api-specification', 200, { json: [] }, key, { 'x-readme-version': version }),
rest.post(`${config.get('host')}/api/v1/api-specification`, async (req, res, ctx) => {
const body = await req.json();
expect(body).toStrictEqual({ registryUUID });
expect(req.headers.get('authorization')).toBeBasicAuthApiKey(key);
return res(ctx.status(201), ctx.set('location', exampleRefLocation), ctx.json({ _id: 1 }));
}),
],
@@ -107,27 +105,25 @@ describe('rdme openapi (single-threaded)', () => {
});

it('should use specified working directory and upload the expected content', async () => {
expect.assertions(5);
expect.assertions(6);
let requestBody;
const registryUUID = getRandomRegistryId();

server.use(
...[
// TODO: basic auth
getAPIMockMSW(`/api/v1/version/${version}`, 'get', 200, { json: { version } }),
getAPIMockMSW(`/api/v1/version/${version}`, 200, { json: { version } }, key),
rest.post(`${config.get('host')}/api/v1/api-registry`, async (req, res, ctx) => {
const body = await req.text();
requestBody = body.substring(body.indexOf('{'), body.lastIndexOf('}') + 1);
requestBody = JSON.parse(requestBody);
expect(body).toMatch('form-data; name="spec"');
return res(ctx.status(201), ctx.json({ registryUUID, spec: { openapi: '3.0.0' } }));
}),
// TODO: basic auth and version header
getAPIMockMSW('/api/v1/api-specification', 'get', 200, { json: [] }),
// TODO: basic auth
getAPIMockMSW('/api/v1/api-specification', 200, { json: [] }, key, { 'x-readme-version': version }),
rest.post(`${config.get('host')}/api/v1/api-specification`, async (req, res, ctx) => {
const body = await req.json();
expect(body).toStrictEqual({ registryUUID });
expect(req.headers.get('authorization')).toBeBasicAuthApiKey(key);
return res(ctx.status(201), ctx.set('location', exampleRefLocation), ctx.json({ _id: 1 }));
}),
],
@@ -155,15 +151,13 @@ describe('rdme openapi (single-threaded)', () => {

server.use(
...[
// TODO: basic auth
getAPIMockMSW(`/api/v1/version/${version}`, 'get', 200, { json: { version } }),
getAPIMockMSW(`/api/v1/version/${version}`, 200, { json: { version } }, key),
rest.post(`${config.get('host')}/api/v1/api-registry`, async (req, res, ctx) => {
const body = await req.text();
expect(body).toMatch('form-data; name="spec"');
return res(ctx.status(201), ctx.json({ registryUUID, spec: { openapi: '3.0.0' } }));
}),
// TODO: basic auth and version header
getAPIMockMSW('/api/v1/api-specification', 'get', 200, { json: [] }),
getAPIMockMSW('/api/v1/api-specification', 200, { json: [] }, key, { 'x-readme-version': version }),
],
);

@@ -209,24 +203,22 @@ describe('rdme openapi (single-threaded)', () => {
});

it('should create GHA workflow (including workingDirectory)', async () => {
expect.assertions(6);
expect.assertions(7);
const yamlFileName = 'openapi-file-workingdirectory';
prompts.inject([true, 'openapi-branch-workingdirectory', yamlFileName]);
const registryUUID = getRandomRegistryId();

server.use(
...[
// TODO: basic auth
getAPIMockMSW(`/api/v1/version/${version}`, 'get', 200, { json: { version } }),
getAPIMockMSW(`/api/v1/version/${version}`, 200, { json: { version } }, key),
rest.post(`${config.get('host')}/api/v1/api-registry`, async (req, res, ctx) => {
const body = await req.text();
expect(body).toMatch('form-data; name="spec"');
return res(ctx.status(201), ctx.json({ registryUUID, spec: { openapi: '3.0.0' } }));
}),
// TODO: basic auth and version header
getAPIMockMSW('/api/v1/api-specification', 'get', 200, { json: [] }),
// TODO: basic auth
getAPIMockMSW('/api/v1/api-specification', 200, { json: [] }, key, { 'x-readme-version': version }),
rest.post(`${config.get('host')}/api/v1/api-specification`, async (req, res, ctx) => {
expect(req.headers.get('authorization')).toBeBasicAuthApiKey(key);
const body = await req.json();
expect(body).toStrictEqual({ registryUUID });
return res(ctx.status(201), ctx.set('location', exampleRefLocation), ctx.json({ _id: 1 }));
@@ -263,21 +255,19 @@ describe('rdme openapi (single-threaded)', () => {
afterEach(afterGHAEnv);

it('should contain request header with correct URL with working directory', async () => {
expect.assertions(7);
expect.assertions(8);
const registryUUID = getRandomRegistryId();
server.use(
...[
// TODO: basic auth
getAPIMockMSW(`/api/v1/version/${version}`, 'get', 200, { json: { version } }),
getAPIMockMSW(`/api/v1/version/${version}`, 200, { json: { version } }, key),
rest.post(`${config.get('host')}/api/v1/api-registry`, async (req, res, ctx) => {
const body = await req.text();
expect(body).toMatch('form-data; name="spec"');
return res(ctx.status(201), ctx.json({ registryUUID, spec: { openapi: '3.0.0' } }));
}),
// TODO: basic auth and version header
getAPIMockMSW('/api/v1/api-specification', 'get', 200, { json: [] }),
// TODO: basic auth
getAPIMockMSW('/api/v1/api-specification', 200, { json: [] }, key, { 'x-readme-version': version }),
rest.post(`${config.get('host')}/api/v1/api-specification`, async (req, res, ctx) => {
expect(req.headers.get('authorization')).toBeBasicAuthApiKey(key);
expect(req.headers.get('x-rdme-ci')).toBe('GitHub Actions (test)');
expect(req.headers.get('x-readme-source')).toBe('cli-gh');
expect(req.headers.get('x-readme-source-url')).toBe(
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -84,6 +84,7 @@
"@types/semver": "^7.3.12",
"@types/validator": "^13.7.6",
"@vitest/coverage-v8": "^0.34.1",
"@vitest/expect": "^0.34.3",
"ajv": "^8.11.0",
"alex": "^11.0.0",
"eslint": "^8.47.0",

0 comments on commit 9d4749f

Please sign in to comment.