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

[TM-1271] Login endpoint #2

Merged
merged 18 commits into from
Sep 18, 2024
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
8 changes: 8 additions & 0 deletions .env.local.sample
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
NODE_ENV=development
PHP_PROXY_TARGET=http://host.docker.internal:8080/api
USER_SERVICE_PROXY_TARGET=http://host.docker.internal:4010

DB_HOST=localhost
DB_PORT=3360
DB_DATABASE=wri_restoration_marketplace_api
DB_USERNAME=wri
DB_PASSWORD=wri

JWT_SECRET=qu3sep4GKdbg6PiVPCKLKljHukXALorq6nLHDBOCSwvs6BrgE6zb8gPmZfrNspKt
17 changes: 8 additions & 9 deletions .github/workflows/ci.yml → .github/workflows/pull-request.yml
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
name: CI

on:
push:
# disabled for now
branches:
- none
# pull_request:
pull_request:

permissions:
actions: read
Expand All @@ -19,11 +15,17 @@ jobs:
with:
fetch-depth: 0

- uses: KengoTODA/actions-setup-docker-compose@v1
with:
version: '2.29.1'

# This enables task distribution via Nx Cloud
# Run this command as early as possible, before dependencies are installed
# Learn more at https://nx.dev/ci/reference/nx-cloud-cli#npx-nxcloud-startcirun
- run: npx nx-cloud start-ci-run --distribute-on="3 linux-medium-js" --stop-agents-after="build"

- run: docker-compose up -d

# Cache node_modules
- uses: actions/setup-node@v4
with:
Expand All @@ -33,7 +35,4 @@ jobs:
- run: npm ci --legacy-peer-deps
- uses: nrwl/nx-set-shas@v4

# Prepend any command with "nx-cloud record --" to record its logs to Nx Cloud
# - run: npx nx-cloud record -- echo Hello World
# Nx Affected runs only tasks affected by the changes in this PR/commit. Learn more: https://nx.dev/ci/features/affected
- run: npx nx affected -t lint test build
- run: npx nx affected -t lint 'test --coverage --passWithNoTests' build
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Repository for the Microservices API backend of the TerraMatch service
* [CDK CLI](https://docs.aws.amazon.com/cdk/v2/guide/getting_started.html) (install globally)
* [AWS SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html)
* [NX](https://nx.dev/getting-started/installation#installing-nx-globally) (install globally)
* [NestJS](https://docs.nestjs.com/) (install globally, useful for development)

# Building and starting the apps
* Copy `.env.local.sample` to `.env`
Expand All @@ -23,3 +24,24 @@ Repository for the Microservices API backend of the TerraMatch service
# Deployment
TBD. The ApiGateway has been tested to be at least functional on AWS. Tooling around deployment will be
handled in a future ticket.

# Database work
For now, Laravel is the source of truth for all things related to the DB schema. As such, TypeORM is not allowed to modify the
schema, and is expected to interface with exactly the schema that is managed by Laravel. This note is included in user.entity.ts,
and should hold true for all models created in this codebase until this codebase can take over as the source of truth for DB
schema:
```
// Note: this has some additional typing information (like width: 1 on bools and type: timestamps on
// CreateDateColumn) to make the types generated here match what is generated by Laravel exactly.
// At this time, we want TypeORM to expect exactly the same types that PHP uses by default. Tested
// by checking what schema gets generated in the test database against the real DB during unit
// test runs (the only time we let TypeORM modify the DB schema).
```

This codebase connects to the database running in the `wri-terramatch-api` docker container. The docker-compose
file included in this repo is used only for setting up the database needed for running unit tests in Github Actions.

To set up the local testing database, run these two commands in the `wri-terramatch-api` directory with the docker container running:
* `echo "grant all on terramatch_microservices_test to 'wri'@'%';" | dc exec -T mariadb mysql -h localhost -u root -proot `
* `echo "grant all on terramatch_microservices_test.* to 'wri'@'%';" | dc exec -T mariadb mysql -h localhost -u root -proot`

8 changes: 0 additions & 8 deletions apps/api-gateway/jest.config.js

This file was deleted.

17 changes: 0 additions & 17 deletions apps/api-gateway/test/api-gateway.test.ts

This file was deleted.

22 changes: 22 additions & 0 deletions apps/user-service/src/app.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { AuthController } from './auth/auth.controller';
import { AuthService } from './auth/auth.service';
import { DatabaseModule } from '@terramatch-microservices/database';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';

@Module({
imports: [
DatabaseModule,
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET'),
}),
})
],
controllers: [AuthController],
providers: [AuthService],
})
export class AppModule {}
22 changes: 0 additions & 22 deletions apps/user-service/src/app/app.controller.spec.ts

This file was deleted.

13 changes: 0 additions & 13 deletions apps/user-service/src/app/app.controller.ts

This file was deleted.

11 changes: 0 additions & 11 deletions apps/user-service/src/app/app.module.ts

This file was deleted.

21 changes: 0 additions & 21 deletions apps/user-service/src/app/app.service.spec.ts

This file was deleted.

8 changes: 0 additions & 8 deletions apps/user-service/src/app/app.service.ts

This file was deleted.

42 changes: 42 additions & 0 deletions apps/user-service/src/auth/auth.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { createMock, DeepMocked } from '@golevelup/ts-jest';
import { UnauthorizedException } from '@nestjs/common';

describe('AuthController', () => {
let controller: AuthController;
let authService: DeepMocked<AuthService>;

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

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

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

it('should throw if creds are invalid', async () => {
authService.login.mockResolvedValue(null);

await expect(() => controller.login({ emailAddress: '[email protected]', password: 'asdfasdfasdf' }))
.rejects
.toThrow(UnauthorizedException)
})

it('returns a token if creds are valid', async () => {
const token = 'fake jwt token';
const userId = 123;
authService.login.mockResolvedValue({ token, userId })

const result = await controller.login({ emailAddress: '[email protected]', password: 'asdfasdfasdf' });
expect(result).toEqual({ type: 'logins', token, id: `${userId}` })
})
});
32 changes: 32 additions & 0 deletions apps/user-service/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Body, Controller, HttpStatus, Post, UnauthorizedException } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LoginRequest } from './dto/login-request.dto';
import { JsonApiResponse } from '../decorators/json-api-response.decorator';
import { LoginResponse } from './dto/login-response.dto';
import { ApiException } from '@nanogiants/nestjs-swagger-api-exception-decorator';
import { ApiOperation } from '@nestjs/swagger';

@Controller('auth')
export class AuthController {
constructor (private readonly authService: AuthService) {}

@Post('login')
@ApiOperation({ summary: 'Receive a JWT Token in exchange for login credentials' })
@JsonApiResponse({ status: HttpStatus.CREATED, dataType: LoginResponse })
@ApiException(
() => UnauthorizedException,
{ description: 'Authentication failed.', template: { statusCode: '$status', message: '$description', } }
)
async login(@Body() { emailAddress, password }: LoginRequest): Promise<LoginResponse> {
const { token, userId } = await this.authService.login(emailAddress, password) ?? {}
if (token == null) {
// there are multiple reasons for the token to be null (bad email address, wrong password),
// but we don't want to report on the specifics because it opens an attack vector: if we
// report that an email address isn't valid, that lets an attacker know which email addresses
// _are_ valid in our system.
throw new UnauthorizedException();
}

return { type: 'logins', id: `${userId}`, token };
}
}
89 changes: 89 additions & 0 deletions apps/user-service/src/auth/auth.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AuthService } from './auth.service';
import { JwtService } from '@nestjs/jwt';
import { createMock, DeepMocked } from '@golevelup/ts-jest';
import { User } from '@terramatch-microservices/database';
import { FactoryGirl, TypeOrmRepositoryAdapter } from 'factory-girl-ts';
import { DataSource } from 'typeorm';
import { UserFactory } from '@terramatch-microservices/database';
import bcrypt from 'bcryptjs';

const dataSource = new DataSource({
type: 'mariadb',
host: 'localhost',
port: 3360,
username: 'wri',
password: 'wri',
// TODO: script to create DB. Going to need a docker container on github actions
database: 'terramatch_microservices_test',
timezone: 'Z',
entities: [User],
synchronize: true,
});

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

beforeAll(async () => {
FactoryGirl.setAdapter(new TypeOrmRepositoryAdapter(dataSource));

await dataSource.initialize();
await dataSource.getRepository(User).delete({});
})

afterAll(async () => {
await dataSource.driver.disconnect();
})

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

service = module.get<AuthService>(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()
})

it('should return null with an invalid password', async () => {
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 token = 'fake jwt token';
jwtService.signAsync.mockReturnValue(Promise.resolve(token));

const result = await service.login(emailAddress, 'fakepassword');

expect(jwtService.signAsync).toHaveBeenCalled();
expect(result.token).toBe(token);
expect(result.userId).toBe(id);
});

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));
jwtService.signAsync.mockResolvedValue('fake jwt token');

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

const { lastLoggedInAt } = user;
await user.reload();
expect(lastLoggedInAt).not.toBe(user.lastLoggedInAt);
})
});
Loading
Loading