Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: run queued client requests on a schedule #1103

Merged
merged 12 commits into from
Jan 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lambda/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ build_all:
@cd db; make build
@cd css-api; make build
@cd siteminder-tests-scheduler; make build
@cd request-queue; make build
227 changes: 227 additions & 0 deletions lambda/__tests__/17.run-queued-requests.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
import { cleanUpDatabaseTables, createMockSendEmail } from './helpers/utils';
import * as IntegrationModule from '@lambda-app/keycloak/integration';
import { formDataProd } from './helpers/fixtures';
import axios from 'axios';
import {
getRequest,
createRequestQueueItem,
generateRequest,
getQueueItems,
getEventsByRequestId,
} from './helpers/modules/integrations';
import { ACTION_TYPES, EMAILS, EVENTS } from '@lambda-shared/enums';
import { QUEUE_ACTION } from '@lambda-shared/interfaces';
import { handler } from '../request-queue/src/main';
import { createEvent, standardClients } from '@lambda-app/controllers/requests';

jest.mock('@lambda-app/keycloak/adminClient');
jest.mock('@lambda-shared/utils/ches');

describe('Request Queue', () => {
beforeEach(async () => {
await cleanUpDatabaseTables();
jest.clearAllMocks();
});

afterAll(async () => {
await cleanUpDatabaseTables();
});

it('Includes existing client id in the queue item body if passed to the standard client', async () => {
const kcClientSpy = jest.spyOn(IntegrationModule, 'keycloakClient');
// Forcing failure so it doesn't remove queue item
kcClientSpy.mockImplementation(() => Promise.resolve(false));

await generateRequest(formDataProd);
await standardClients(formDataProd, true, 'existing-id');
const queueItems = await getQueueItems();
expect(queueItems.length).toBe(1);
expect(queueItems[0].request.existingClientId).toBe('existing-id');
});

it('Creates the integrations from the queue, removes queue item, creates event and sends email if successful', async () => {
const kcClientSpy = jest.spyOn(IntegrationModule, 'keycloakClient');
kcClientSpy.mockImplementation(() => Promise.resolve(true));
const emailResults = createMockSendEmail();

await createRequestQueueItem(1, formDataProd, ACTION_TYPES.CREATE as QUEUE_ACTION);
const request = await generateRequest(formDataProd);
expect(request.status).toBe('draft');

await handler();
// Called for each environment
expect(kcClientSpy).toHaveBeenCalledTimes(3);

// Check the queue is cleared
const queueItems = await getQueueItems();
expect(queueItems.length).toBe(0);

// Check the status updated
const updatedRequest = await getRequest(request.id);
expect(updatedRequest.status).toBe('applied');

// Check expected email sent
expect(emailResults.length).toBe(1);
const email = emailResults[0];
expect(email.code).toBe(EMAILS.CREATE_INTEGRATION_APPLIED);
expect(email.to.includes(formDataProd.user.idirEmail)).toBeTruthy();

const events = await getEventsByRequestId(request.id);
expect(events.length).toBe(1);
expect(events[0].eventCode).toBe(EVENTS.REQUEST_APPLY_SUCCESS);
kcClientSpy.mockRestore();
});

it('Sends an update email if request was previously applied', async () => {
const kcClientSpy = jest.spyOn(IntegrationModule, 'keycloakClient');
kcClientSpy.mockImplementation(() => Promise.resolve(true));
const emailResults = createMockSendEmail();

await createRequestQueueItem(1, formDataProd, ACTION_TYPES.UPDATE as QUEUE_ACTION);
const request = await generateRequest(formDataProd);
// Insert a previous applied event
await createEvent({ eventCode: EVENTS.REQUEST_APPLY_SUCCESS, requestId: request.id });

await handler();

// Check expected email sent
expect(emailResults.length).toBe(1);
const email = emailResults[0];
expect(email.code).toBe(EMAILS.UPDATE_INTEGRATION_APPLIED);
expect(email.to.includes(formDataProd.user.idirEmail)).toBeTruthy();
});

it('Keeps item in the queue if any environments fail and updates request status to failed', async () => {
const kcClientSpy = jest.spyOn(IntegrationModule, 'keycloakClient');

await createRequestQueueItem(1, formDataProd, ACTION_TYPES.CREATE as QUEUE_ACTION);
const request = await generateRequest(formDataProd);

// Have one environment fail
kcClientSpy.mockResolvedValueOnce(true).mockResolvedValueOnce(false).mockResolvedValueOnce(true);

await handler();

const queueItems = await getQueueItems();
expect(queueItems.length).toBe(1);

const updatedRequest = await getRequest(request.id);
expect(updatedRequest.status).toBe('applyFailed');
kcClientSpy.mockRestore();
});
});

describe('Delete and Update', () => {
const setupKCMock = ({ clients = {}, roles = {}, clientScopes = {} }) => {
const kcAdminClient = {
clients: {
find: jest.fn(() => Promise.resolve([])),
del: jest.fn(),
create: jest.fn((client) => ({ ...client, id: 1 })),
update: jest.fn(),
listProtocolMappers: jest.fn(() => Promise.resolve([])),
listRoles: jest.fn(() => Promise.resolve([])),
listDefaultClientScopes: jest.fn(() => Promise.resolve([])),
addDefaultClientScope: jest.fn(),
listOptionalClientScopes: jest.fn(() => Promise.resolve([])),
addOptionalClientScope: jest.fn(),
addProtocolMapper: jest.fn(),
...clients,
},
roles: {
findOneByName: jest.fn(),
delByName: jest.fn(),
create: jest.fn(),
...roles,
},
clientScopes: {
find: jest.fn(() => Promise.resolve([])),
...clientScopes,
},
};
const mockedAdminClient = require('@lambda-app/keycloak/adminClient');
mockedAdminClient.getAdminClient.mockImplementation(() => ({
kcAdminClient,
}));
return kcAdminClient;
};

beforeEach(async () => {
jest.clearAllMocks();
(axios.get as jest.Mock).mockImplementation(() => Promise.resolve({ data: [] }));
});

afterEach(async () => {
await cleanUpDatabaseTables();
});

it('Deletes client if archived is true and does not send any email', async () => {
const archivedData = { ...formDataProd, archived: true };
const kcAdminClient = setupKCMock({
clients: {
find: jest.fn(() => Promise.resolve([archivedData])),
},
});
await createRequestQueueItem(1, archivedData, ACTION_TYPES.DELETE as QUEUE_ACTION);
await generateRequest(archivedData);
const emailResults = createMockSendEmail();

await handler();
expect(kcAdminClient.clients.del).toHaveBeenCalled();
expect(emailResults.length).toBe(0);
});

it('Updates client if not archived but already exists', async () => {
const kcAdminClient = setupKCMock({
clients: {
find: jest.fn(() => Promise.resolve([formDataProd])),
},
});
await createRequestQueueItem(1, formDataProd, ACTION_TYPES.CREATE as QUEUE_ACTION);
await generateRequest(formDataProd);

await handler();
expect(kcAdminClient.clients.del).not.toHaveBeenCalled();
expect(kcAdminClient.clients.update).toHaveBeenCalled();
});

it('Uses the existing client id if saved in the queue data', async () => {
const existingClientId = 'existing-id-test';
const kcAdminClient = setupKCMock({
clients: {
find: jest.fn(() => Promise.resolve([formDataProd])),
},
});
const request = await generateRequest(formDataProd);
await createRequestQueueItem(
request.id,
{ ...formDataProd, existingClientId: existingClientId },
ACTION_TYPES.CREATE as QUEUE_ACTION,
);

await handler();

expect(kcAdminClient.clients.find).toHaveBeenCalledWith({ clientId: existingClientId, max: 1, realm: 'standard' });
});

it("Uses the integration's client id if existing client id is an empty string", async () => {
const kcAdminClient = setupKCMock({
clients: {
find: jest.fn(() => Promise.resolve([formDataProd])),
},
});
const request = await generateRequest(formDataProd);
await createRequestQueueItem(
request.id,
{ ...formDataProd, existingClientId: '' },
ACTION_TYPES.CREATE as QUEUE_ACTION,
);
await handler();

expect(kcAdminClient.clients.find).toHaveBeenCalledWith({
clientId: formDataProd.clientId,
max: 1,
realm: 'standard',
});
});
});
4 changes: 4 additions & 0 deletions lambda/__tests__/__mocks__/axios.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export default {
post: jest.fn(() => Promise.resolve({})),
get: jest.fn(() => Promise.resolve({})),
};
1 change: 1 addition & 0 deletions lambda/__tests__/helpers/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export const formDataDev: IntegrationData = {
projectLead: true,
newToSso: true,
agreeWithTerms: true,
protocol: 'oidc',
status: 'draft',
archived: false,
idirUserDisplayName: 'test user',
Expand Down
19 changes: 18 additions & 1 deletion lambda/__tests__/helpers/modules/integrations.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import app from '../../helpers/server';
import supertest from 'supertest';
import { APP_BASE_PATH } from '../constants';
import { IntegrationData } from '@lambda-shared/interfaces';
import { IntegrationData, QUEUE_ACTION } from '@lambda-shared/interfaces';
import { models } from '@lambda-shared/sequelize/models/models';

export const createIntegration = async (data: IntegrationData = {}) => {
return await supertest(app).post(`${APP_BASE_PATH}/requests`).send(data).set('Accept', 'application/json');
Expand Down Expand Up @@ -62,3 +63,19 @@ export const fetchMetrics = async (integrationId: number, fromDate: string, toDa
`${APP_BASE_PATH}/requests/${integrationId}/metrics?fromDate=${fromDate}&toDate=${toDate}&env=${env}`,
);
};

interface RequestData extends IntegrationData {
existingClientId?: string;
}

export const createRequestQueueItem = async (requestId: number, requestData: RequestData, action: QUEUE_ACTION) => {
return models.requestQueue.create({ type: 'request', action, requestId, request: requestData });
};

export const getQueueItems = async () => models.requestQueue.findAll();

export const getRequest = async (id: number) => models.request.findOne({ where: { id } });

export const generateRequest = async (data: IntegrationData) => models.request.create(data);

export const getEventsByRequestId = async (id: number) => models.event.findAll({ where: { requestId: id } });
6 changes: 3 additions & 3 deletions lambda/app/src/controllers/requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -696,7 +696,7 @@ export const processIntegrationRequest = async (
}
};

const standardClients = async (
export const standardClients = async (
integration: IntegrationData,
restore: boolean = false,
existingClientId: string = '',
Expand All @@ -706,7 +706,7 @@ const standardClients = async (
type: REQUEST_TYPES.INTEGRATION,
action: integration.archived ? ACTION_TYPES.DELETE : ACTION_TYPES.UPDATE,
requestId: integration.id,
request: integration,
request: { ...integration, existingClientId },
});
if (!queueItem) {
await models.request.update({ status: 'planFailed' }, { where: { id: integration?.id } });
Expand Down Expand Up @@ -740,7 +740,7 @@ const standardClients = async (
return true;
};

const updatePlannedIntegration = async (integration: IntegrationData) => {
export const updatePlannedIntegration = async (integration: IntegrationData) => {
const updatedIntegration = await models.request.findOne({
where: {
id: integration.id,
Expand Down
10 changes: 5 additions & 5 deletions lambda/app/src/keycloak/integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export const openIdClientProfile = (
webOrigins: validRedirectUris.concat('+'),
fullScopeAllowed: false,
authenticationFlowBindingOverrides: {
browser: authFlows.find((flow) => flow.alias === integration.browserFlowOverride).id || '',
browser: authFlows.find((flow) => flow.alias === integration.browserFlowOverride)?.id || '',
direct_grant: '',
},
};
Expand Down Expand Up @@ -90,7 +90,7 @@ export const samlClientProfile = (
redirectUris: validRedirectUris,
fullScopeAllowed: false,
authenticationFlowBindingOverrides: {
browser: authFlows.find((flow) => flow.alias === integration.browserFlowOverride).id || '',
browser: authFlows.find((flow) => flow.alias === integration.browserFlowOverride)?.id || '',
direct_grant: '',
},
};
Expand All @@ -105,7 +105,7 @@ export const keycloakClient = async (
try {
let client;

if (isPreservedClaim(integration.additionalRoleAttribute.trim())) {
if (isPreservedClaim(integration.additionalRoleAttribute?.trim())) {
throw Error(`${integration.additionalRoleAttribute} is a preserved claim and cannot be overwritten`);
}

Expand Down Expand Up @@ -201,7 +201,7 @@ export const keycloakClient = async (
for (const scope of defaultScopes.filter((n: string) => !existingDefaultScopes.includes(n))) {
await kcAdminClient.clients.addDefaultClientScope({
id: client.id,
clientScopeId: clientScopeList.find((defaultClientscope) => defaultClientscope.name === scope).id,
clientScopeId: clientScopeList.find((defaultClientscope) => defaultClientscope.name === scope)?.id,
realm,
});
}
Expand All @@ -216,7 +216,7 @@ export const keycloakClient = async (
if (existingOptionalScopes.includes('offline_access')) {
await kcAdminClient.clients.addOptionalClientScope({
id: client.id,
clientScopeId: clientScopeList.find((defaultClientscope) => defaultClientscope.name === 'offline_access').id,
clientScopeId: clientScopeList.find((defaultClientscope) => defaultClientscope.name === 'offline_access')?.id,
realm,
});
}
Expand Down
20 changes: 20 additions & 0 deletions lambda/request-queue/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
#!make

SHELL := /usr/bin/env bash
TARGET := ../../../terraform/lambda-request-queue.zip

.PHONY: install
install:
@yarn install

.PHONY: uninstall
uninstall:
@rm -rf node_modules
@rm -f yarn.lock

.PHONY: build
build: install
build:
@rm -rf dist
@yarn build
@cd dist; rm -f $(TARGET); zip -rq $(TARGET) *
25 changes: 25 additions & 0 deletions lambda/request-queue/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Request Queue

This lambda function is built to periodically check the request queue table, and rerun any requests that failed to apply earlier.

## Getting started

To install dependencies:

- `yarn`

To build:

- `yarn build`

To run this functions tests, from the [lambda directory](../) run:

- `yarn jest 17`

## Running

This queue is run on a schedule with cloudwatch, see the [lambda definition](../../terraform/lambda-request-queue.tf) and [cloudwatch definition](../../terraform/cloudwatch.tf) for details. To generate the zip file used by the terraform lambda, run `make build` from this directory.

## Testing

See [17.run-queued-requests.test.ts](../__tests__/17.run-queued-requests.test.ts) for tests related to this function.
Loading
Loading