Skip to content

Commit

Permalink
[TM-1312] Set up sequelize and factory girl for all tests.
Browse files Browse the repository at this point in the history
  • Loading branch information
roguenet committed Oct 4, 2024
1 parent 5d0bb30 commit 596947b
Show file tree
Hide file tree
Showing 18 changed files with 348 additions and 201 deletions.
54 changes: 21 additions & 33 deletions apps/user-service/src/auth/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,42 +2,22 @@ import { Test, TestingModule } from '@nestjs/testing';
import { AuthService } from './auth.service';
import { JwtService } from '@nestjs/jwt';
import { createMock, DeepMocked } from '@golevelup/ts-jest';
import { FactoryGirl, SequelizeAdapter } from 'factory-girl-ts';
import bcrypt from 'bcryptjs';
import { User } from '@terramatch-microservices/database/entities';
import { UserFactory } from '@terramatch-microservices/database/factories';
import { Sequelize } from 'sequelize-typescript';
import * as Entities from '@terramatch-microservices/database/entities';

describe('AuthService', () => {
let service: AuthService;
let jwtService: DeepMocked<JwtService>;

const sequelize = new Sequelize({
dialect: 'mariadb',
host: 'localhost',
port: 3360,
username: 'wri',
password: 'wri',
database: 'terramatch_microservices_test',
models: Object.values(Entities),
logging: false,
});

beforeAll(async () => {
await sequelize.sync({ force: true });
FactoryGirl.setAdapter(new SequelizeAdapter());
})

afterAll(async () => {
await sequelize.close();
})

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AuthService,
{ provide: JwtService, useValue: jwtService = createMock<JwtService>() }
{
provide: JwtService,
useValue: (jwtService = createMock<JwtService>()),
},
],
}).compile();

Expand All @@ -46,21 +26,27 @@ describe('AuthService', () => {

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

it('should return null with invalid email', async () => {
jest.spyOn(User, 'findOne').mockImplementation(() => Promise.resolve(null));
expect(await service.login('[email protected]', 'asdfasdfsadf')).toBeNull()
})
expect(await service.login('[email protected]', 'asdfasdfsadf')).toBeNull();
});

it('should return null with an invalid password', async () => {
const { emailAddress } = await UserFactory.create({ password: 'fakepasswordhash' });
const { emailAddress } = await UserFactory.create({
password: 'fakepasswordhash',
});
expect(await service.login(emailAddress, 'fakepassword')).toBeNull();
})
});

it('should return a token and id with a valid password', async () => {
const { id, emailAddress } = await UserFactory.create({ password: 'fakepasswordhash' });
jest.spyOn(bcrypt, 'compare').mockImplementation(() => Promise.resolve(true));
const { id, emailAddress } = await UserFactory.create({
password: 'fakepasswordhash',
});
jest
.spyOn(bcrypt, 'compare')
.mockImplementation(() => Promise.resolve(true));

const token = 'fake jwt token';
jwtService.signAsync.mockReturnValue(Promise.resolve(token));
Expand All @@ -74,13 +60,15 @@ describe('AuthService', () => {

it('should update the last logged in date on the user', async () => {
const user = await UserFactory.create({ password: 'fakepasswordhash' });
jest.spyOn(bcrypt, 'compare').mockImplementation(() => Promise.resolve(true));
jest
.spyOn(bcrypt, 'compare')
.mockImplementation(() => Promise.resolve(true));
jwtService.signAsync.mockResolvedValue('fake jwt token');

await service.login(user.emailAddress, 'fakepassword');

const { lastLoggedInAt } = user;
await user.reload();
expect(lastLoggedInAt).not.toBe(user.lastLoggedInAt);
})
});
});
2 changes: 1 addition & 1 deletion apps/user-service/src/auth/login.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export class LoginController {
constructor(private readonly authService: AuthService) {}

@Post()
@NoBearerAuth()
@NoBearerAuth
@ApiOperation({
operationId: 'authLogin',
description: 'Receive a JWT Token in exchange for login credentials',
Expand Down
4 changes: 3 additions & 1 deletion jest.preset.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,7 @@ module.exports = {
lines: 95,
statements: 95,
}
}
},

setupFilesAfterEnv: ['./setup-jest.ts'],
}
30 changes: 29 additions & 1 deletion libs/common/src/lib/guards/auth.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { AuthGuard } from './auth.guard';
import { AuthGuard, NoBearerAuth } from './auth.guard';
import { Test } from '@nestjs/testing';
import { APP_GUARD } from '@nestjs/core';
import { createMock, DeepMocked } from '@golevelup/ts-jest';
Expand All @@ -12,6 +12,12 @@ class TestController {
test() {
return 'test';
}

@NoBearerAuth
@Get('/no-auth')
noAuth() {
return 'no-auth';
}
}

describe('AuthGuard', () => {
Expand All @@ -31,11 +37,33 @@ describe('AuthGuard', () => {

afterEach(async () => {
await app.close();
jest.restoreAllMocks();
});

it('should return an error when no auth header is present', async () => {
await request(app.getHttpServer())
.get('/test')
.expect(HttpStatus.UNAUTHORIZED);
});

it('should not return an error when a valid auth header is present', async () => {
const token = 'fake jwt token';
jwtService.verifyAsync.mockResolvedValue({ sub: 'fakeuserid' });

await request(app.getHttpServer())
.get('/test')
.set('Authorization', `Bearer ${token}`)
.expect(HttpStatus.OK);
});

it('should ignore bearer token on an endpoint with @NoBearerAuth', async () => {
await request(app.getHttpServer())
.get('/test/no-auth')
.expect(HttpStatus.OK);

await request(app.getHttpServer())
.get('/test/no-auth')
.set('Authorization', 'Bearer fake jwt token')
.expect(HttpStatus.OK);
});
});
2 changes: 1 addition & 1 deletion libs/common/src/lib/guards/auth.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { JwtService } from '@nestjs/jwt';
import { Reflector } from '@nestjs/core';

const NO_BEARER_AUTH = 'noBearerAuth';
export const NoBearerAuth = () => SetMetadata(NO_BEARER_AUTH, true);
export const NoBearerAuth = SetMetadata(NO_BEARER_AUTH, true);

@Injectable()
export class AuthGuard implements CanActivate {
Expand Down
21 changes: 21 additions & 0 deletions libs/common/src/lib/log.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
const IS_PROD = process.env['NODE_ENV'] === 'production';
const IS_TEST = process.env['NODE_ENV'] === 'test';

// TODO: Add Sentry support
export default class Log {
static debug(message: any, ...optionalParams: any[]) {
if (!IS_PROD && !IS_TEST) console.debug(message, ...optionalParams);
}

static info(message: any, ...optionalParams: any[]) {
if (!IS_PROD && !IS_TEST) console.info(message, ...optionalParams);
}

static warn(message: any, ...optionalParams: any[]) {
if (!IS_TEST) console.warn(message, ...optionalParams);
}

static error(message: any, ...optionalParams: any[]) {
if (!IS_TEST) console.error(message, ...optionalParams);
}
}
44 changes: 44 additions & 0 deletions libs/common/src/lib/policies/policy.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Test, TestingModule } from '@nestjs/testing';
import { PolicyService } from './policy.service';
import { UnauthorizedException } from '@nestjs/common';
import {
ModelHasRole,
User,
} from '@terramatch-microservices/database/entities';
import { RequestContext } from 'nestjs-request-context';

export function mockUserId(userId?: number) {
jest
.spyOn(RequestContext, 'currentContext', 'get')
.mockReturnValue({ req: { authenticatedUserId: userId }, res: {} });
}

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

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [PolicyService],
}).compile();

service = module.get<PolicyService>(PolicyService);
});

afterEach(async () => {
jest.restoreAllMocks();
});

it('should throw an error if no authed user is found', async () => {
mockUserId();
await expect(() => service.authorize('foo', new User())).rejects.toThrow(
UnauthorizedException
);
});

it('should throw an error if there is no policy defined', async () => {
mockUserId(123);
await expect(() => service.authorize('foo', new ModelHasRole())).rejects.toThrow(
UnauthorizedException
);
});
});
4 changes: 3 additions & 1 deletion libs/common/src/lib/policies/policy.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import { BuilderType, EntityPolicy } from './entity.policy';
import { Permission, User } from '@terramatch-microservices/database/entities';
import { AbilityBuilder, createMongoAbility } from '@casl/ability';
import { Model } from 'sequelize-typescript';
import Log from '../log';

type EntityClass = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
new (...args: any[]): Model;
name?: string;
};

type PolicyClass = {
Expand Down Expand Up @@ -38,7 +40,7 @@ export class PolicyService {

const [, PolicyClass] = POLICIES.find(([entityClass]) => subject instanceof entityClass) ?? [];
if (PolicyClass == null) {
console.error('No policy found for subject type', subject);
Log.error('No policy found for subject type', subject.constructor.name);
throw new UnauthorizedException();
}

Expand Down
6 changes: 3 additions & 3 deletions libs/database/src/lib/entities/framework.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ import { BIGINT, STRING } from 'sequelize';
export class Framework extends Model {
@PrimaryKey
@AutoIncrement
@Column({ type: BIGINT.UNSIGNED })
@Column(BIGINT.UNSIGNED)
override id: number;

@Column({ type: STRING(20) })
@Column(STRING(20))
slug: string;

@Column
@Column(STRING)
name: string;
}
8 changes: 4 additions & 4 deletions libs/database/src/lib/entities/model-has-role.entity.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import { Column, ForeignKey, Model, Table } from 'sequelize-typescript';
import { Role } from './role.entity';
import { BIGINT } from 'sequelize';
import { BIGINT, STRING } from 'sequelize';

@Table({ tableName: 'model_has_roles', underscored: true, timestamps: false })
export class ModelHasRole extends Model {
@ForeignKey(() => Role)
@Column({ type: BIGINT.UNSIGNED, primaryKey: true })
roleId: number;

@Column({ primaryKey: true })
@Column({ type: STRING, primaryKey: true })
modelType: string;

@Column({ primaryKey: true })
modelId: bigint;
@Column({ type: BIGINT.UNSIGNED, primaryKey: true })
modelId: number;
}
8 changes: 4 additions & 4 deletions libs/database/src/lib/entities/organisation-user.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,17 @@ import { User } from './user.entity';
export class OrganisationUser extends Model {
@PrimaryKey
@AutoIncrement
@Column({ type: BIGINT.UNSIGNED })
@Column(BIGINT.UNSIGNED)
override id: number;

@ForeignKey(() => User)
@Column({ type: BIGINT.UNSIGNED})
@Column(BIGINT.UNSIGNED)
userId: number;

@ForeignKey(() => Organisation)
@Column({ type: BIGINT.UNSIGNED })
@Column(BIGINT.UNSIGNED)
organisationId: number;

@Column({ type: STRING(20) })
@Column(STRING(20))
status: string;
}
Loading

0 comments on commit 596947b

Please sign in to comment.