From e7cf4e09c8f1901809210946a1efe39443db16f5 Mon Sep 17 00:00:00 2001 From: ian Date: Mon, 3 Apr 2023 22:47:56 +0800 Subject: [PATCH] Add cookie logger tests (#17932) Co-authored-by: Pascal Jufer Co-authored-by: Brainslug --- .gitignore | 1 + tests-blackbox/common/index.ts | 1 + tests-blackbox/common/test-logger.ts | 61 +++++ tests-blackbox/common/transport.ts | 5 +- tests-blackbox/logger/redact.test.ts | 262 +++++++++++++++++++++ tests-blackbox/routes/auth/refresh.test.ts | 189 +++++++++++++++ tests-blackbox/setup/sequentialTests.js | 1 + 7 files changed, 518 insertions(+), 2 deletions(-) create mode 100644 tests-blackbox/common/test-logger.ts create mode 100644 tests-blackbox/logger/redact.test.ts create mode 100644 tests-blackbox/routes/auth/refresh.test.ts diff --git a/.gitignore b/.gitignore index 58565319f6..3b4e1e953d 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ TODO debug debug.ts .clinic +/uploads diff --git a/tests-blackbox/common/index.ts b/tests-blackbox/common/index.ts index 279179b402..a0fd8056ab 100644 --- a/tests-blackbox/common/index.ts +++ b/tests-blackbox/common/index.ts @@ -3,3 +3,4 @@ export * from './functions'; export * from './seed-functions'; export * from './types'; export * from './transport'; +export * from './test-logger'; diff --git a/tests-blackbox/common/test-logger.ts b/tests-blackbox/common/test-logger.ts new file mode 100644 index 0000000000..2ee532817d --- /dev/null +++ b/tests-blackbox/common/test-logger.ts @@ -0,0 +1,61 @@ +import { ChildProcess } from 'child_process'; + +export class TestLogger { + private logs: string; + private server: ChildProcess; + private stopCondition: string; + private filterCondition?: string; + private stopped?: boolean; + private resolve?: (log: string) => void; + + /** + * + * @param server Process running a Directus instance + * @param stopCondition Finish as soon as the specified string appears in the logs. + * @param filter Only capture log chunks containing the specified string, if `true` uses the same string as defined for `stopCondition` + */ + constructor(server: ChildProcess, stopCondition: string, filterCondition?: boolean | string) { + this.logs = ''; + this.server = server; + this.stopCondition = stopCondition; + if (filterCondition) { + this.filterCondition = filterCondition === true ? stopCondition : filterCondition; + } + + // Discard data up to this point + server.stdout?.read(); + + server.stdout?.on('data', this.processChunks); + } + + private processChunks = (chunk: any) => { + const logLine = String(chunk); + + if (!this.stopped && (!this.filterCondition || logLine.includes(this.filterCondition))) { + this.logs += logLine; + } + + if (this.logs.includes(this.stopCondition)) { + this.stopped = true; + this.cleanup(); + + if (this.resolve) { + this.resolve(this.logs); + } + } + }; + + getLogs = () => { + return new Promise((resolve) => { + if (this.stopped) { + resolve(this.logs); + } else { + this.resolve = resolve; + } + }); + }; + + cleanup = () => { + this.server.stdout?.off('data', this.processChunks); + }; +} diff --git a/tests-blackbox/common/transport.ts b/tests-blackbox/common/transport.ts index 4ff43401c8..0845572fe0 100644 --- a/tests-blackbox/common/transport.ts +++ b/tests-blackbox/common/transport.ts @@ -10,16 +10,17 @@ export async function requestGraphQL( isSystemCollection: boolean, token: string | null, jsonQuery: any, - variables?: any + options?: { variables?: any; cookies?: string[] } ): Promise { const req = request(host) .post(isSystemCollection ? '/graphql/system' : '/graphql') .send({ query: processGraphQLJson(jsonQuery), - variables, + variables: options?.variables, }); if (token) req.set('Authorization', `Bearer ${token}`); + if (options?.cookies) req.set('Cookie', options.cookies); return await req; } diff --git a/tests-blackbox/logger/redact.test.ts b/tests-blackbox/logger/redact.test.ts new file mode 100644 index 0000000000..0c26ad629a --- /dev/null +++ b/tests-blackbox/logger/redact.test.ts @@ -0,0 +1,262 @@ +import config, { getUrl } from '@common/config'; +import vendors from '@common/get-dbs-to-test'; +import * as common from '@common/index'; +import { TestLogger } from '@common/test-logger'; +import { awaitDirectusConnection } from '@utils/await-connection'; +import { ChildProcess, spawn } from 'child_process'; +import { EnumType } from 'json-to-graphql-query'; +import knex, { Knex } from 'knex'; +import { cloneDeep } from 'lodash'; +import request from 'supertest'; + +describe('Logger Redact Tests', () => { + const databases = new Map(); + const directusInstances = {} as { [vendor: string]: ChildProcess }; + const env = cloneDeep(config.envs); + const authModes = ['json', 'cookie']; + + for (const vendor of vendors) { + env[vendor].LOG_STYLE = 'raw'; + env[vendor].LOG_LEVEL = 'info'; + env[vendor].PORT = String(Number(env[vendor]!.PORT) + 500); + } + + beforeAll(async () => { + const promises = []; + + for (const vendor of vendors) { + databases.set(vendor, knex(config.knexConfig[vendor]!)); + + const server = spawn('node', ['api/cli', 'start'], { env: env[vendor] }); + directusInstances[vendor] = server; + + promises.push(awaitDirectusConnection(Number(env[vendor].PORT))); + } + + // Give the server some time to start + await Promise.all(promises); + }, 180000); + + afterAll(async () => { + for (const [vendor, connection] of databases) { + directusInstances[vendor]!.kill(); + + await connection.destroy(); + } + }); + + describe('POST /refresh', () => { + describe('refreshes with refresh_token in the body', () => { + describe.each(authModes)('for %s mode', (mode) => { + common.TEST_USERS.forEach((userKey) => { + describe(common.USER[userKey].NAME, () => { + it.each(vendors)('%s', async (vendor) => { + // Setup + const refreshToken = ( + await request(getUrl(vendor, env)) + .post(`/auth/login`) + .send({ email: common.USER[userKey].EMAIL, password: common.USER[userKey].PASSWORD }) + .expect('Content-Type', /application\/json/) + ).body.data.refresh_token; + + const refreshToken2 = ( + await common.requestGraphQL(getUrl(vendor, env), true, null, { + mutation: { + auth_login: { + __args: { + email: common.USER[userKey].EMAIL, + password: common.USER[userKey].PASSWORD, + }, + refresh_token: true, + }, + }, + }) + ).body.data.auth_login.refresh_token; + + // Action + const logger = new TestLogger(directusInstances[vendor], '/auth/refresh', true); + + const response = await request(getUrl(vendor, env)) + .post(`/auth/refresh`) + .send({ refresh_token: refreshToken, mode }) + .expect('Content-Type', /application\/json/); + + const logs = await logger.getLogs(); + + const loggerGql = new TestLogger(directusInstances[vendor], '/graphql/system', true); + + const mutationKey = 'auth_refresh'; + + const gqlResponse = await common.requestGraphQL(getUrl(vendor, env), true, null, { + mutation: { + [mutationKey]: { + __args: { + refresh_token: refreshToken2, + mode: new EnumType(mode), + }, + access_token: true, + expires: true, + refresh_token: true, + }, + }, + }); + + const logsGql = await loggerGql.getLogs(); + + // Assert + expect(response.statusCode).toBe(200); + if (mode === 'cookie') { + expect(response.body).toMatchObject({ + data: { + access_token: expect.any(String), + expires: expect.any(Number), + }, + }); + + for (const log of [logs, logsGql]) { + expect((log.match(/"cookie":"--redact--"/g) || []).length).toBe(0); + expect((log.match(/"set-cookie":"--redact--"/g) || []).length).toBe(1); + } + } else { + expect(response.body).toMatchObject({ + data: { + access_token: expect.any(String), + expires: expect.any(Number), + refresh_token: expect.any(String), + }, + }); + + for (const log of [logs, logsGql]) { + expect((log.match(/"cookie":"--redact--"/g) || []).length).toBe(0); + expect((log.match(/"set-cookie":"--redact--"/g) || []).length).toBe(0); + } + } + + expect(gqlResponse.statusCode).toBe(200); + expect(gqlResponse.body).toMatchObject({ + data: { + [mutationKey]: { + access_token: expect.any(String), + expires: expect.any(String), + refresh_token: expect.any(String), + }, + }, + }); + }); + }); + }); + }); + }); + + describe('refreshes with refresh_token in the cookie', () => { + describe.each(authModes)('for %s mode', (mode) => { + common.TEST_USERS.forEach((userKey) => { + describe(common.USER[userKey].NAME, () => { + it.each(vendors)('%s', async (vendor) => { + // Setup + const cookieName = 'directus_refresh_token'; + + const refreshToken = ( + await request(getUrl(vendor, env)) + .post(`/auth/login`) + .send({ email: common.USER[userKey].EMAIL, password: common.USER[userKey].PASSWORD }) + .expect('Content-Type', /application\/json/) + ).body.data.refresh_token; + + const refreshToken2 = ( + await common.requestGraphQL(getUrl(vendor, env), true, null, { + mutation: { + auth_login: { + __args: { + email: common.USER[userKey].EMAIL, + password: common.USER[userKey].PASSWORD, + }, + refresh_token: true, + }, + }, + }) + ).body.data.auth_login.refresh_token; + + // Action + const logger = new TestLogger(directusInstances[vendor], '/auth/refresh', true); + + const response = await request(getUrl(vendor, env)) + .post(`/auth/refresh`) + .set('Cookie', `${cookieName}=${refreshToken}`) + .send({ mode }) + .expect('Content-Type', /application\/json/); + + const logs = await logger.getLogs(); + + const loggerGql = new TestLogger(directusInstances[vendor], '/graphql/system', true); + + const mutationKey = 'auth_refresh'; + + const gqlResponse = await common.requestGraphQL( + getUrl(vendor, env), + true, + null, + { + mutation: { + [mutationKey]: { + __args: { + refresh_token: refreshToken2, + mode: new EnumType(mode), + }, + access_token: true, + expires: true, + refresh_token: true, + }, + }, + }, + { cookies: [`${cookieName}=${refreshToken2}`] } + ); + + const logsGql = await loggerGql.getLogs(); + + // Assert + expect(response.statusCode).toBe(200); + if (mode === 'cookie') { + expect(response.body).toMatchObject({ + data: { + access_token: expect.any(String), + expires: expect.any(Number), + }, + }); + + for (const log of [logs, logsGql]) { + expect((log.match(/"cookie":"--redact--"/g) || []).length).toBe(1); + expect((log.match(/"set-cookie":"--redact--"/g) || []).length).toBe(1); + } + } else { + expect(response.body).toMatchObject({ + data: { + access_token: expect.any(String), + expires: expect.any(Number), + refresh_token: expect.any(String), + }, + }); + + for (const log of [logs, logsGql]) { + expect((log.match(/"cookie":"--redact--"/g) || []).length).toBe(1); + expect((log.match(/"set-cookie":"--redact--"/g) || []).length).toBe(0); + } + } + + expect(gqlResponse.statusCode).toBe(200); + expect(gqlResponse.body).toMatchObject({ + data: { + [mutationKey]: { + access_token: expect.any(String), + expires: expect.any(String), + refresh_token: expect.any(String), + }, + }, + }); + }); + }); + }); + }); + }); + }); +}); diff --git a/tests-blackbox/routes/auth/refresh.test.ts b/tests-blackbox/routes/auth/refresh.test.ts new file mode 100644 index 0000000000..74c7095193 --- /dev/null +++ b/tests-blackbox/routes/auth/refresh.test.ts @@ -0,0 +1,189 @@ +import { getUrl } from '@common/config'; +import * as common from '@common/index'; +import request from 'supertest'; +import vendors from '@common/get-dbs-to-test'; +import { requestGraphQL } from '@common/index'; +import { EnumType } from 'json-to-graphql-query'; + +const authModes = ['json', 'cookie']; + +describe('Authentication Refresh Tests', () => { + describe('POST /refresh', () => { + describe('refreshes with refresh_token in the body', () => { + describe.each(authModes)('for %s mode', (mode) => { + common.TEST_USERS.forEach((userKey) => { + describe(common.USER[userKey].NAME, () => { + it.each(vendors)('%s', async (vendor) => { + // Setup + const refreshToken = ( + await request(getUrl(vendor)) + .post(`/auth/login`) + .send({ email: common.USER[userKey].EMAIL, password: common.USER[userKey].PASSWORD }) + .expect('Content-Type', /application\/json/) + ).body.data.refresh_token; + + const refreshToken2 = ( + await requestGraphQL(getUrl(vendor), true, null, { + mutation: { + auth_login: { + __args: { + email: common.USER[userKey].EMAIL, + password: common.USER[userKey].PASSWORD, + }, + refresh_token: true, + }, + }, + }) + ).body.data.auth_login.refresh_token; + + // Action + const response = await request(getUrl(vendor)) + .post(`/auth/refresh`) + .send({ refresh_token: refreshToken, mode }) + .expect('Content-Type', /application\/json/); + + const mutationKey = 'auth_refresh'; + + const gqlResponse = await requestGraphQL(getUrl(vendor), true, null, { + mutation: { + [mutationKey]: { + __args: { + refresh_token: refreshToken2, + mode: new EnumType(mode), + }, + access_token: true, + expires: true, + refresh_token: true, + }, + }, + }); + + // Assert + expect(response.statusCode).toBe(200); + if (mode === 'cookie') { + expect(response.body).toMatchObject({ + data: { + access_token: expect.any(String), + expires: expect.any(Number), + }, + }); + } else { + expect(response.body).toMatchObject({ + data: { + access_token: expect.any(String), + expires: expect.any(Number), + refresh_token: expect.any(String), + }, + }); + } + + expect(gqlResponse.statusCode).toBe(200); + expect(gqlResponse.body).toMatchObject({ + data: { + [mutationKey]: { + access_token: expect.any(String), + expires: expect.any(String), + refresh_token: expect.any(String), + }, + }, + }); + }); + }); + }); + }); + }); + + describe('refreshes with refresh_token in the cookie', () => { + describe.each(authModes)('for %s mode', (mode) => { + common.TEST_USERS.forEach((userKey) => { + describe(common.USER[userKey].NAME, () => { + it.each(vendors)('%s', async (vendor) => { + // Setup + const cookieName = 'directus_refresh_token'; + + const refreshToken = ( + await request(getUrl(vendor)) + .post(`/auth/login`) + .send({ email: common.USER[userKey].EMAIL, password: common.USER[userKey].PASSWORD }) + .expect('Content-Type', /application\/json/) + ).body.data.refresh_token; + + const refreshToken2 = ( + await requestGraphQL(getUrl(vendor), true, null, { + mutation: { + auth_login: { + __args: { + email: common.USER[userKey].EMAIL, + password: common.USER[userKey].PASSWORD, + }, + refresh_token: true, + }, + }, + }) + ).body.data.auth_login.refresh_token; + + // Action + const response = await request(getUrl(vendor)) + .post(`/auth/refresh`) + .set('Cookie', `${cookieName}=${refreshToken}`) + .send({ mode }) + .expect('Content-Type', /application\/json/); + + const mutationKey = 'auth_refresh'; + + const gqlResponse = await requestGraphQL( + getUrl(vendor), + true, + null, + { + mutation: { + [mutationKey]: { + __args: { + refresh_token: refreshToken2, + mode: new EnumType(mode), + }, + access_token: true, + expires: true, + refresh_token: true, + }, + }, + }, + { cookies: [`${cookieName}=${refreshToken2}`] } + ); + + // Assert + expect(response.statusCode).toBe(200); + if (mode === 'cookie') { + expect(response.body).toMatchObject({ + data: { + access_token: expect.any(String), + expires: expect.any(Number), + }, + }); + } else { + expect(response.body).toMatchObject({ + data: { + access_token: expect.any(String), + expires: expect.any(Number), + refresh_token: expect.any(String), + }, + }); + } + + expect(gqlResponse.statusCode).toBe(200); + expect(gqlResponse.body).toMatchObject({ + data: { + [mutationKey]: { + access_token: expect.any(String), + expires: expect.any(String), + refresh_token: expect.any(String), + }, + }, + }); + }); + }); + }); + }); + }); + }); +}); diff --git a/tests-blackbox/setup/sequentialTests.js b/tests-blackbox/setup/sequentialTests.js index 9d8997e382..6ba7f71bb9 100644 --- a/tests-blackbox/setup/sequentialTests.js +++ b/tests-blackbox/setup/sequentialTests.js @@ -11,6 +11,7 @@ exports.list = { { testFilePath: '/schema/timezone/timezone.test.ts' }, { testFilePath: '/schema/timezone/timezone-changed-node-tz-america.test.ts' }, { testFilePath: '/schema/timezone/timezone-changed-node-tz-asia.test.ts' }, + { testFilePath: '/logger/redact.test.ts' }, { testFilePath: '/routes/collections/schema-cache.test.ts' }, { testFilePath: '/routes/assets/concurrency.test.ts' }, ],