Skip to content

Commit

Permalink
Merge pull request #6456 from NMDSdevopsServiceAdm/feat/1568-forgot-u…
Browse files Browse the repository at this point in the history
…sername-page-second-half

Feat/1568 forgot username page second half
  • Loading branch information
kapppa-joe authored Dec 16, 2024
2 parents be346e1 + fa2257b commit 6f77467
Show file tree
Hide file tree
Showing 14 changed files with 655 additions and 22 deletions.
30 changes: 30 additions & 0 deletions backend/server/models/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -520,5 +520,35 @@ module.exports = function (sequelize, DataTypes) {
return this.findOne(query);
};

User.getUsernameWithSecurityQuestionAnswer = async function ({ uid, securityQuestionAnswer }) {
if (!uid || !securityQuestionAnswer) {
return null;
}

const query = {
attributes: ['SecurityQuestionAnswerValue'],
where: {
Archived: false,
uid,
},
include: [
{
model: sequelize.models.login,
required: true,
attributes: ['username'],
},
],
raw: true,
};

const userFound = await this.findOne(query);

if (!userFound || userFound.SecurityQuestionAnswerValue !== securityQuestionAnswer) {
return null;
}

return { username: userFound['login.username'] };
};

return User;
};
3 changes: 3 additions & 0 deletions backend/server/routes/registration.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const generateJWT = require('../utils/security/generateJWT');
const sendMail = require('../utils/email/notify-email').sendPasswordReset;
const { authLimiter } = require('../utils/middleware/rateLimiting');
const { findUserAccount } = require('./registration/findUserAccount');
const { findUsername } = require('./registration/findUsername');

router.use('/establishmentExistsCheck', require('./registration/establishmentExistsCheck'));

Expand Down Expand Up @@ -370,4 +371,6 @@ router.post('/validateResetPassword', async (req, res) => {

router.post('/findUserAccount', findUserAccount);

router.post('/findUsername', findUsername);

module.exports = router;
45 changes: 45 additions & 0 deletions backend/server/routes/registration/findUsername.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
const { isEmpty } = require('lodash');
const models = require('../../models/index');

const findUsername = async (req, res) => {
try {
if (requestIsInvalid(req)) {
return res.status(400).send('Invalid request');
}

const { uid, securityQuestionAnswer } = req.body;
const userFound = await models.user.getUsernameWithSecurityQuestionAnswer({ uid, securityQuestionAnswer });

if (!userFound) {
return sendFailedResponse(res);
}

return sendSuccessResponse(res, userFound);
} catch (err) {
console.error('registration POST findUsername - failed', err);
return res.status(500).send('Internal server error');
}
};

const requestIsInvalid = (req) => {
if (!req.body) {
return true;
}
const { securityQuestionAnswer, uid } = req.body;

return [securityQuestionAnswer, uid].some((field) => isEmpty(field));
};

const sendSuccessResponse = (res, userFound) => {
const { username } = userFound;
return res.status(200).json({
answerCorrect: true,
username,
});
};

const sendFailedResponse = (res) => {
return res.status(401).json({ answerCorrect: false, remainingAttempts: 4 });
};

module.exports = { findUsername };
123 changes: 123 additions & 0 deletions backend/server/test/unit/routes/registration/findUsername.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
const chai = require('chai');
const sinon = require('sinon');
const expect = chai.expect;
const httpMocks = require('node-mocks-http');

const { findUsername } = require('../../../../routes/registration/findUsername');
const models = require('../../../../models/index');

describe('backend/server/routes/registration/findUsername', () => {
const mockRequestBody = {
uid: 'mock-uid',
securityQuestionAnswer: '42',
};

const buildRequest = (body) => {
const request = {
method: 'POST',
url: '/api/registration/findUsername',
body,
};
return httpMocks.createRequest(request);
};

let stubGetUsername;

beforeEach(() => {
stubGetUsername = sinon
.stub(models.user, 'getUsernameWithSecurityQuestionAnswer')
.callsFake(({ securityQuestionAnswer }) => {
if (securityQuestionAnswer === '42') {
return { username: 'test-user' };
}
return null;
});
});

afterEach(() => {
sinon.restore();
});

it('should respond with 200 and username if securityQuestionAnswer is correct', async () => {
const req = buildRequest(mockRequestBody);
const res = httpMocks.createResponse();

await findUsername(req, res);

expect(res.statusCode).to.equal(200);
expect(res._getJSONData()).to.deep.equal({
answerCorrect: true,
username: 'test-user',
});

expect(stubGetUsername).to.have.been.calledWith({
uid: 'mock-uid',
securityQuestionAnswer: '42',
});
});

it('should respond with 401 Unauthorised and number of remainingAttempts if securityQuestionAnswer is incorrect', async () => {
const req = buildRequest({ uid: 'mock-uid', securityQuestionAnswer: 'some random thing' });
const res = httpMocks.createResponse();

await findUsername(req, res);

expect(res.statusCode).to.equal(401);
expect(res._getJSONData()).to.deep.equal({
answerCorrect: false,
remainingAttempts: 4,
});
});

it('should respond with 400 error if request does not have a body', async () => {
const req = httpMocks.createRequest({
method: 'POST',
url: '/api/registration/findUsername',
});
const res = httpMocks.createResponse();

await findUsername(req, res);

expect(res.statusCode).to.equal(400);
});

it('should respond with 400 error if request body is empty', async () => {
const req = buildRequest({});
const res = httpMocks.createResponse();

await findUsername(req, res);

expect(res.statusCode).to.equal(400);
});

it('should respond with 400 error if securityQuestionAnswer is missing', async () => {
const req = buildRequest({ uid: mockRequestBody.uid });
const res = httpMocks.createResponse();

await findUsername(req, res);

expect(res.statusCode).to.equal(400);
});

it('should respond with 400 error if uid is missing', async () => {
const req = buildRequest({ securityQuestionAnswer: mockRequestBody.securityQuestionAnswer });
const res = httpMocks.createResponse();

await findUsername(req, res);

expect(res.statusCode).to.equal(400);
});

it('should respond with 500 Internal server error if an error occur when finding user', async () => {
const req = buildRequest(mockRequestBody);
const res = httpMocks.createResponse();

sinon.stub(console, 'error'); // suppress noisy logging
stubGetUsername.rejects(new Error('mock database error'));

await findUsername(req, res);

expect(res.statusCode).to.equal(500);
expect(res._getData()).to.equal('Internal server error');
});
});
25 changes: 25 additions & 0 deletions frontend/src/app/core/services/find-username.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,29 @@ describe('FindUsernameService', () => {
expect(req.request.body).toEqual(mockParams);
});
});

describe('findUsername', () => {
it('should make a POST request to /registration/findUsername endpoint with uid and security question answer', async () => {
const mockParams = { uid: 'mock-uid', securityQuestionAnswer: '42' };

service.findUsername(mockParams).subscribe();
const req = http.expectOne(`${environment.appRunnerEndpoint}/api/registration/findUsername`);

expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual(mockParams);
});

it('should handle a 401 Unauthorised response and convert it to AnswerIncorrect', async () => {
const mockParams = { uid: 'mock-uid', securityQuestionAnswer: '42' };

const mockSubscriber = jasmine.createSpy();
service.findUsername(mockParams).subscribe(mockSubscriber);
const req = http.expectOne(`${environment.appRunnerEndpoint}/api/registration/findUsername`);
req.flush({ answerCorrect: false, remainingAttempts: 4 }, { status: 401, statusText: 'Unauthorised' });

const expectedResult = { answerCorrect: false, remainingAttempts: 4 };

expect(mockSubscriber).toHaveBeenCalledOnceWith(expectedResult);
});
});
});
40 changes: 38 additions & 2 deletions frontend/src/app/core/services/find-username.service.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { Observable } from 'rxjs';
import { Observable, of, throwError } from 'rxjs';
import { environment } from 'src/environments/environment';

import { HttpClient } from '@angular/common/http';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { catchError } from 'rxjs/operators';

export interface FindAccountRequest {
name: string;
Expand All @@ -22,6 +23,22 @@ interface AccountNotFound {

export type FindUserAccountResponse = AccountFound | AccountNotFound;

export interface FindUsernameRequest {
uid: string;
securityQuestionAnswer: string;
}

interface AnswerCorrect {
answerCorrect: true;
username: string;
}
interface AnswerIncorrect {
answerCorrect: false;
remainingAttempts: number;
}

export type FindUsernameResponse = AnswerCorrect | AnswerIncorrect;

@Injectable({
providedIn: 'root',
})
Expand All @@ -36,4 +53,23 @@ export class FindUsernameService {
params,
);
}

findUsername(params: FindUsernameRequest): Observable<any> {
return this.http
.post<FindUsernameResponse>(`${environment.appRunnerEndpoint}/api/registration/findUsername`, params)
.pipe(catchError((res) => this.handleFindUsernameErrors(res)));
}

handleFindUsernameErrors(err: HttpErrorResponse): Observable<AnswerIncorrect> {
if (err.status === 401) {
const remainingAttempts = err.error?.remainingAttempts ?? 0;

return of({
answerCorrect: false,
remainingAttempts,
});
}

throwError(err);
}
}
25 changes: 23 additions & 2 deletions frontend/src/app/core/test-utils/MockFindUsernameService.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { Injectable } from '@angular/core';
import { FindAccountRequest, FindUsernameService } from '@core/services/find-username.service';
import { of } from 'rxjs';
import { FindAccountRequest, FindUsernameRequest, FindUsernameService } from '@core/services/find-username.service';
import { Observable, of } from 'rxjs';

export const mockTestUser = {
accountUid: 'mock-user-uid',
securityQuestion: 'What is your favourite colour?',
securityQuestionAnswer: 'Blue',
username: 'mock-test-user',
};

@Injectable()
export class MockFindUsernameService extends FindUsernameService {
Expand All @@ -18,4 +25,18 @@ export class MockFindUsernameService extends FindUsernameService {
securityQuestion: 'What is your favourite colour?',
});
}

findUsername(params: FindUsernameRequest): ReturnType<FindUsernameService['findUsername']> {
if (params.securityQuestionAnswer === mockTestUser.securityQuestionAnswer) {
return of({
answerCorrect: true,
username: mockTestUser.username,
});
}

return of({
answerCorrect: false,
remainingAttempts: 4,
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@
<div #searchResult>
<ng-container *ngIf="submitted">
<ng-container *ngIf="accountFound; else accountNotFound">
<h2 class="govuk-heading-m">Account found</h2>
<h2 class="govuk-heading-m govuk-!-margin-bottom-0">
<span class="govuk__flex govuk__align-items-center">
<img class="govuk-!-margin-right-2" src="/assets/images/tick-icon.svg" alt="" />Account found
</span>
</h2>
</ng-container>

<ng-template #accountNotFound>
Expand Down
Loading

0 comments on commit 6f77467

Please sign in to comment.