Skip to content
Open
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
2 changes: 2 additions & 0 deletions .github/workflows/jest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ jobs:
echo "AWS_SECRET_ACCESS_KEY=dummy" >> .env
echo "AWS_ACCESS_KEY_ID=dummy" >> .env
echo "AWS_REGION=us-east-2" >> .env
echo "COGNITO_APP_CLIENT_ID=dummy" >> .env
echo "COGNITO_USER_POOL_ID=dummy" >> .env

- name: Install Dependencies
run: yarn install
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AdminLifecycleController } from './admin-lifecycle.controller';
import { AdminLifecycleService } from './admin-lifecycle.service';
import { RolesGuard } from '../auth/roles.guard';
import { UsersService } from '../users/users.service';

jest.mock('../util/aws-exports', () => ({
__esModule: true,
default: {
CognitoAuthConfig: {
userPoolId: 'test-user-pool-id',
clientId: 'test-client-id',
},
AWSConfig: { region: 'us-east-2' },
PublicFrontendUrl: 'https://app.test',
},
}));

describe('AdminLifecycleController', () => {
let controller: AdminLifecycleController;

const mockAdminLifecycleService = {
listAdmins: jest.fn(),
deactivateAdmin: jest.fn(),
reactivateAdmin: jest.fn(),
};

const mockRolesGuard = { canActivate: jest.fn(() => true) };
const mockUsersService = { findOne: jest.fn() };

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AdminLifecycleController],
providers: [
{ provide: AdminLifecycleService, useValue: mockAdminLifecycleService },
{ provide: UsersService, useValue: mockUsersService },
{ provide: RolesGuard, useValue: mockRolesGuard },
],
}).compile();

controller = module.get<AdminLifecycleController>(AdminLifecycleController);
});

afterEach(() => {
jest.clearAllMocks();
});

it('should be defined', () => {
expect(controller).toBeDefined();
});

it('lists admins via the service', async () => {
const admins = [
{
email: 'ada@example.com',
firstName: 'Ada',
lastName: 'Lovelace',
isActive: true,
},
];
mockAdminLifecycleService.listAdmins.mockResolvedValue(admins);

await expect(controller.listAdmins()).resolves.toEqual(admins);
expect(mockAdminLifecycleService.listAdmins).toHaveBeenCalledTimes(1);
});

it('delegates deactivation to the service', async () => {
const result = { email: 'ada@example.com', isActive: false };
mockAdminLifecycleService.deactivateAdmin.mockResolvedValue(result);

await expect(
controller.deactivateAdmin('ada@example.com'),
).resolves.toEqual(result);
expect(mockAdminLifecycleService.deactivateAdmin).toHaveBeenCalledWith(
'ada@example.com',
);
});

it('delegates reactivation to the service', async () => {
const result = { email: 'ada@example.com', isActive: true };
mockAdminLifecycleService.reactivateAdmin.mockResolvedValue(result);

await expect(
controller.reactivateAdmin('ada@example.com'),
).resolves.toEqual(result);
expect(mockAdminLifecycleService.reactivateAdmin).toHaveBeenCalledWith(
'ada@example.com',
);
});
});
57 changes: 57 additions & 0 deletions apps/backend/src/admin-provisioning/admin-lifecycle.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { Controller, Get, Param, Patch, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { RolesGuard } from '../auth/roles.guard';
import { Roles } from '../auth/roles.decorator';
import { UserType } from '../users/types';
import { AdminLifecycleService } from './admin-lifecycle.service';
import {
AdminAccountSummary,
AdminLifecycleResult,
} from './admin-lifecycle.types';

/**
* Admin-only endpoints for managing the admin account lifecycle: listing
* admins and deactivating / directly reactivating accounts (the requester's own
* account or another admin's).
*/
@ApiTags('Admin Lifecycle')
@ApiBearerAuth()
@Controller('admins')
@UseGuards(AuthGuard('jwt'), RolesGuard)
export class AdminLifecycleController {
constructor(private readonly adminLifecycleService: AdminLifecycleService) {}

/**
* Lists all admin accounts with their active status.
*/
@Get()
@Roles(UserType.ADMIN)
async listAdmins(): Promise<AdminAccountSummary[]> {
return this.adminLifecycleService.listAdmins();
}

/**
* Deactivates an admin account so it can no longer authenticate.
* @param email the admin to deactivate.
*/
@Patch(':email/deactivate')
@Roles(UserType.ADMIN)
async deactivateAdmin(
@Param('email') email: string,
): Promise<AdminLifecycleResult> {
return this.adminLifecycleService.deactivateAdmin(email);
}

/**
* Reactivates an admin account directly (admin-initiated).
* @param email the admin to reactivate.
*/
@Patch(':email/reactivate')
@Roles(UserType.ADMIN)
async reactivateAdmin(
@Param('email') email: string,
): Promise<AdminLifecycleResult> {
return this.adminLifecycleService.reactivateAdmin(email);
}
}
205 changes: 205 additions & 0 deletions apps/backend/src/admin-provisioning/admin-lifecycle.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import {
AdminDisableUserCommand,
AdminEnableUserCommand,
AdminUserGlobalSignOutCommand,
} from '@aws-sdk/client-cognito-identity-provider';
import {
BadRequestException,
ConflictException,
NotFoundException,
} from '@nestjs/common';
import { AdminLifecycleService } from './admin-lifecycle.service';
import { User } from '../users/user.entity';
import { COGNITO_IDENTITY_PROVIDER } from './cognito.provider';
import { UserType } from '../users/types';

jest.mock('../util/aws-exports', () => ({
__esModule: true,
default: {
CognitoAuthConfig: {
userPoolId: 'test-user-pool-id',
clientId: 'test-client-id',
},
AWSConfig: {
region: 'us-east-2',
},
},
}));

const makeAdmin = (overrides: Partial<User> = {}): User => ({
email: 'ada@example.com',
firstName: 'Ada',
lastName: 'Lovelace',
userType: UserType.ADMIN,
isActive: true,
...overrides,
});

describe('AdminLifecycleService', () => {
let service: AdminLifecycleService;

const mockCognitoIdentityProvider = {
send: jest.fn(),
};

const mockUserRepository = {
find: jest.fn(),
findOneBy: jest.fn(),
count: jest.fn(),
save: jest.fn(),
};

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AdminLifecycleService,
{
provide: COGNITO_IDENTITY_PROVIDER,
useValue: mockCognitoIdentityProvider,
},
{
provide: getRepositoryToken(User),
useValue: mockUserRepository,
},
],
}).compile();

service = module.get<AdminLifecycleService>(AdminLifecycleService);
mockCognitoIdentityProvider.send.mockResolvedValue({});
mockUserRepository.save.mockImplementation(async (user) => user);
});

afterEach(() => {
jest.clearAllMocks();
});

it('should be defined', () => {
expect(service).toBeDefined();
});

describe('listAdmins', () => {
it('returns admins with their active status', async () => {
mockUserRepository.find.mockResolvedValue([
makeAdmin(),
makeAdmin({ email: 'bob@example.com', isActive: false }),
]);

const result = await service.listAdmins();

expect(mockUserRepository.find).toHaveBeenCalledWith({
where: { userType: UserType.ADMIN },
});
expect(result).toEqual([
{
email: 'ada@example.com',
firstName: 'Ada',
lastName: 'Lovelace',
isActive: true,
},
{
email: 'bob@example.com',
firstName: 'Ada',
lastName: 'Lovelace',
isActive: false,
},
]);
});
});

describe('deactivateAdmin', () => {
it('global-signs-out, disables Cognito, and flips isActive', async () => {
mockUserRepository.findOneBy.mockResolvedValue(makeAdmin());
mockUserRepository.count.mockResolvedValue(2);

const result = await service.deactivateAdmin('Ada@Example.com');

const firstCommand = mockCognitoIdentityProvider.send.mock.calls[0][0];
const secondCommand = mockCognitoIdentityProvider.send.mock.calls[1][0];
expect(firstCommand).toBeInstanceOf(AdminUserGlobalSignOutCommand);
expect(firstCommand.input).toEqual({
UserPoolId: 'test-user-pool-id',
Username: 'ada@example.com',
});
expect(secondCommand).toBeInstanceOf(AdminDisableUserCommand);
expect(secondCommand.input).toEqual({
UserPoolId: 'test-user-pool-id',
Username: 'ada@example.com',
});
expect(mockUserRepository.save).toHaveBeenCalledWith(
expect.objectContaining({ email: 'ada@example.com', isActive: false }),
);
expect(result).toEqual({ email: 'ada@example.com', isActive: false });
});

it('refuses to deactivate the last active admin', async () => {
mockUserRepository.findOneBy.mockResolvedValue(makeAdmin());
mockUserRepository.count.mockResolvedValue(1);

await expect(service.deactivateAdmin('ada@example.com')).rejects.toThrow(
ConflictException,
);
expect(mockCognitoIdentityProvider.send).not.toHaveBeenCalled();
expect(mockUserRepository.save).not.toHaveBeenCalled();
});

it('skips the last-admin guard when the target is already inactive', async () => {
mockUserRepository.findOneBy.mockResolvedValue(
makeAdmin({ isActive: false }),
);

await service.deactivateAdmin('ada@example.com');

expect(mockUserRepository.count).not.toHaveBeenCalled();
expect(mockCognitoIdentityProvider.send).toHaveBeenCalledTimes(2);
});

it('throws NotFound when the user does not exist', async () => {
mockUserRepository.findOneBy.mockResolvedValue(null);

await expect(
service.deactivateAdmin('missing@example.com'),
).rejects.toThrow(NotFoundException);
});

it('throws BadRequest when the user is not an admin', async () => {
mockUserRepository.findOneBy.mockResolvedValue(
makeAdmin({ userType: UserType.STANDARD }),
);

await expect(service.deactivateAdmin('ada@example.com')).rejects.toThrow(
BadRequestException,
);
});
});

describe('reactivateAdmin', () => {
it('enables the Cognito user and flips isActive', async () => {
mockUserRepository.findOneBy.mockResolvedValue(
makeAdmin({ isActive: false }),
);

const result = await service.reactivateAdmin('ada@example.com');

const command = mockCognitoIdentityProvider.send.mock.calls[0][0];
expect(command).toBeInstanceOf(AdminEnableUserCommand);
expect(command.input).toEqual({
UserPoolId: 'test-user-pool-id',
Username: 'ada@example.com',
});
expect(mockUserRepository.save).toHaveBeenCalledWith(
expect.objectContaining({ email: 'ada@example.com', isActive: true }),
);
expect(result).toEqual({ email: 'ada@example.com', isActive: true });
});

it('throws NotFound when the user does not exist', async () => {
mockUserRepository.findOneBy.mockResolvedValue(null);

await expect(
service.reactivateAdmin('missing@example.com'),
).rejects.toThrow(NotFoundException);
});
});
});
Loading
Loading