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: allow realm deletion #138

Merged
merged 10 commits into from
Dec 18, 2023
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
213 changes: 213 additions & 0 deletions app/__tests__/api/delete-realm.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import { createMocks } from 'node-mocks-http';
import deleteHandler from '../../pages/api/realms/[id]';
import githubResponseHandler from '../../pages/api/realms/pending';
import prisma from 'utils/prisma';
import { CustomRealmProfiles } from '../fixtures';
import { getServerSession } from 'next-auth';
import { createCustomRealmPullRequest, mergePullRequest, deleteBranch } from 'utils/github';
import { EventEnum, StatusEnum } from 'validators/create-realm';
import { removeUserAsRealmAdmin } from 'controllers/keycloak';
import { sendDeletionCompleteEmail } from 'utils/mailer';

jest.mock('../../controllers/keycloak', () => {
return {
removeUserAsRealmAdmin: jest.fn(),
};
});

jest.mock('../../utils/mailer', () => {
return {
sendUpdateEmail: jest.fn(),
sendDeleteEmail: jest.fn(),
sendDeletionCompleteEmail: jest.fn(() => Promise.resolve(true)),
};
});

jest.mock('../../utils/github', () => {
return {
createCustomRealmPullRequest: jest.fn(() => Promise.resolve({ data: { number: 1 } })),
mergePullRequest: jest.fn(() => Promise.resolve({ data: { merged: true } })),
deleteBranch: jest.fn(),
};
});

jest.mock('next/config', () => () => ({
serverRuntimeConfig: {
gh_api_token: 'secret',
},
}));

const mockSession = {
expires: new Date(Date.now() + 2 * 86400).toISOString(),
user: {
username: 'test',
family_name: 'test',
idir_username: 'test',
},
status: 'authenticated',
};

jest.mock('next-auth/next', () => {
return {
__esModule: true,
getServerSession: jest.fn(() => mockSession),
};
});

jest.mock('../../pages/api/auth/[...nextauth]', () => {
return {
__esModule: true,
authOptions: {},
};
});

const mockAdminSession = () => {
(getServerSession as jest.Mock).mockImplementation(() => {
return {
...mockSession,
user: {
...mockSession.user,
client_roles: ['sso-admin'],
},
};
});
};

describe('Realm Delete Request', () => {
beforeEach(() => {
jest.clearAllMocks();
// Mock prisma find/update functions
(prisma.roster.findUnique as jest.Mock).mockImplementation(() => {
return Promise.resolve({ ...CustomRealmProfiles[0], id: 1 });
});
(prisma.roster.update as jest.Mock).mockImplementation(() => {
return Promise.resolve({});
});
(prisma.event.create as jest.Mock).mockImplementation(() => {
return Promise.resolve({});
});
});

it('Only allows admins to delete realms', async () => {
const { req, res } = createMocks({
method: 'DELETE',
query: { id: 1 },
});
await deleteHandler(req, res);
expect(res.statusCode).toBe(401);
expect(createCustomRealmPullRequest).not.toHaveBeenCalled();
expect(mergePullRequest).not.toHaveBeenCalled();
expect(deleteBranch).not.toHaveBeenCalled();

mockAdminSession();
await deleteHandler(req, res);
expect(res.statusCode).toBe(200);
expect(createCustomRealmPullRequest).toHaveBeenCalled();
expect(mergePullRequest).toHaveBeenCalled();
expect(deleteBranch).toHaveBeenCalled();
});

it('Updates the status, archived, and prNumber when deleted successfully', async () => {
const { req, res } = createMocks({
method: 'DELETE',
query: { id: 1 },
});
mockAdminSession();
await deleteHandler(req, res);
expect(res.statusCode).toBe(200);

const rosterUpdateArgs = (prisma.roster.update as jest.Mock).mock.calls[0][0];
expect(rosterUpdateArgs.data.archived).toBe(true);
expect(rosterUpdateArgs.data.prNumber).toBe(1);
expect(rosterUpdateArgs.data.status).toBe(StatusEnum.PRSUCCESS);
});

it('Updates the status to failed if the pr fails or merge fails and logs an event', async () => {
// PR Creation failure
const failureEvent = {
data: {
realmId: 1,
eventCode: EventEnum.REQUEST_DELETE_FAILED,
idirUserId: 'test',
},
};
const { req, res } = createMocks({
method: 'DELETE',
query: { id: 1 },
});
(createCustomRealmPullRequest as jest.Mock).mockImplementationOnce(() => Promise.reject(new Error('Failed')));
mockAdminSession();
await deleteHandler(req, res);
expect(res.statusCode).toBe(500);

let rosterUpdateArgs = (prisma.roster.update as jest.Mock).mock.calls[0][0];
expect(rosterUpdateArgs.data.status).toBe(StatusEnum.PRFAILED);
expect(prisma.event.create).toHaveBeenCalledWith(failureEvent);

// PR merge failure
(mergePullRequest as jest.Mock).mockImplementationOnce(() => Promise.reject(new Error('Failed')));
await deleteHandler(req, res);

expect(res.statusCode).toBe(500);
rosterUpdateArgs = (prisma.roster.update as jest.Mock).mock.calls[0][0];
expect(rosterUpdateArgs.data.status).toBe(StatusEnum.PRFAILED);
expect(prisma.event.create).toHaveBeenCalledWith(failureEvent);
});
});

describe('Github Actions Delete', () => {
const mockToken = 'secret';
beforeEach(() => {
jest.clearAllMocks();
(prisma.roster.findUnique as jest.Mock).mockImplementation(() => {
return Promise.resolve({ ...CustomRealmProfiles[0], id: 1, archived: true });
});
});
const requestData = {
method: 'PUT' as 'PUT',
body: {
ids: [1],
action: 'tf_apply',
success: 'true',
},
headers: {
Authorization: mockToken,
},
};

it('requires api token', async () => {
let { req, res } = createMocks(requestData);
await githubResponseHandler(req, res);
expect(res.statusCode).toBe(200);

// Remove auth header
({ req, res } = createMocks({ ...requestData, headers: { Authorization: 'empty' } }));
await githubResponseHandler(req, res);
expect(res.statusCode).toBe(401);
});

it('Removes technical contact and product owner from all envirionments', async () => {
const { req, res } = createMocks(requestData);
await githubResponseHandler(req, res);
expect(res.statusCode).toBe(200);

// Email only sent once
expect(sendDeletionCompleteEmail).toHaveBeenCalledTimes(1);

// PO email and technical contact email removed in each realm
['dev', 'test', 'prod'].forEach((env) => {
expect(removeUserAsRealmAdmin).toHaveBeenCalledWith(['[email protected]', '[email protected]'], env, 'realm 1');
});
// No extra calls
expect(removeUserAsRealmAdmin).toHaveBeenCalledTimes(3);
});

it('Only sends deletion complete email if all users removed successfully', async () => {
const { req, res } = createMocks(requestData);
(removeUserAsRealmAdmin as jest.Mock).mockImplementationOnce(() => Promise.reject(new Error('failure')));
await githubResponseHandler(req, res);

expect(res.statusCode).toBe(500);
expect(sendDeletionCompleteEmail).not.toHaveBeenCalled();
});
});
43 changes: 43 additions & 0 deletions app/controllers/keycloak.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,49 @@
import { RoleMappingPayload } from 'keycloak-admin/lib/defs/roleRepresentation';
import KeycloakCore from 'utils/keycloak-core';

/**
* Function to remove access at the master realm level as administrator of a custom realm. Custom realm owners access control comes from the role <realmname>-realm-admin. Removes this role from supplied usernames if found.
* @param emails Usernames to remove
* @param env The environment to cleanup
* @param realm The realm name you want to remove access to
*/
export const removeUserAsRealmAdmin = async (emails: (string | null)[], env: string, realm: string) => {
const kcCore = new KeycloakCore(env);
const kcAdminClient = await kcCore.getAdminClient();
const definedEmails = emails.filter((name) => name) as string[];

const userPromises = definedEmails.map((email) =>
kcAdminClient.users.find({
realm: 'master',
email,
}),
);

const users = await Promise.all(userPromises);

const userIds = users.map((user) => user?.[0]?.id).filter((user) => user) as string[];

if (userIds.length === 0) {
console.info(`No users found as admin for realm ${realm}.`);
return;
}

const role = await kcAdminClient.roles.findOneByName({ realm: 'master', name: `${realm}-realm-admins` });

if (role === null) return;

const roleMapping: RoleMappingPayload = { id: role?.id as string, name: role?.name as string };

const delRoleMappingPromises = userIds.map((id) =>
kcAdminClient.users.delRealmRoleMappings({
realm: 'master',
id: id,
roles: [roleMapping],
}),
);
return Promise.all(delRoleMappingPromises);
};

export const addUserAsRealmAdmin = async (username: string, envs: string[], realmName: string) => {
for (const env of envs) {
const kcCore = new KeycloakCore(env);
Expand Down
65 changes: 63 additions & 2 deletions app/pages/api/realms/[id].ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
mergePullRequest,
} from 'utils/github';
import omit from 'lodash.omit';
import { sendUpdateEmail } from 'utils/mailer';
import { sendDeleteEmail, sendUpdateEmail } from 'utils/mailer';

interface ErrorData {
success: boolean;
Expand All @@ -22,7 +22,7 @@ interface ErrorData {
type Data = ErrorData | string;

export default async function handler(req: NextApiRequest, res: NextApiResponse<Data>) {
let username;
let username: string;
let currentRequest;
try {
const session = await getServerSession(req, res, authOptions);
Expand Down Expand Up @@ -226,6 +226,67 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
console.error(err);
return res.status(500).json({ success: false, error: 'update failed' });
}
} else if (req.method === 'DELETE') {
const { id } = req.query;
if (!isAdmin) return res.status(401).send('Unauthorized');

const realm = await prisma.roster.findUnique({
where: {
id: parseInt(id as string, 10),
archived: false,
},
});

if (!realm) return res.status(404).send('Not found');

const prResponse: CreatePullRequestResponseType | null = await createCustomRealmPullRequest(
realm.realm!,
realm.environments,
false,
).catch((err) => {
console.error(`Error creating pr for realm id ${id}: ${err}`);
return null;
});

const failPR = () => {
return Promise.all([
prisma.roster.update({ data: { status: StatusEnum.PRFAILED }, where: { id: parseInt(id as string, 10) } }),
createEvent({
realmId: parseInt(req.query.id as string, 10),
eventCode: EventEnum.REQUEST_DELETE_FAILED,
idirUserId: username,
}),
]);
};

if (!prResponse?.data.number) {
console.info(`PR Failed for deletion of realm id ${id}`);
// Intentionally not awaiting since 500 already, handling error async
failPR().catch((err) => console.error(err));
return res.status(500).send('Unexpected error removing request. Please try again.');
}

const pr = await mergePullRequest(prResponse.data.number);
if (!pr.data.merged) {
console.info(`Failed to merge pull request for realm id ${id}`);
failPR().catch((err) => console.error(err));
return res.status(500).send('Unexpected error removing request. Please try again.');
}

await deleteBranch(realm.realm!);

prisma.roster.update({
data: {
archived: true,
prNumber: prResponse.data.number,
status: StatusEnum.PRSUCCESS,
},
where: {
id: parseInt(id as string, 10),
},
});
sendDeleteEmail(realm, session);
res.status(200).send('Success');
} else {
return res.status(404).json({ success: false, error: 'not found' });
}
Expand Down
Loading
Loading