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

refactor: migrate /spcp/validate to TypeScript #656

Merged
merged 15 commits into from
Nov 18, 2020
Merged
99 changes: 0 additions & 99 deletions src/app/controllers/spcp.server.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ const { isEmpty } = require('lodash')
const mongoose = require('mongoose')
const crypto = require('crypto')
const { StatusCodes } = require('http-status-codes')
const axios = require('axios')

const { createReqMeta } = require('../utils/request')
const logger = require('../../config/logger').createLoggerWithLabel(module)
Expand Down Expand Up @@ -202,104 +201,6 @@ const handleOOBAuthenticationWith = (ndiConfig, authType, extractUser) => {
}
}

/**
* Generates redirect URL to Official SingPass/CorpPass log in page
* @param {Object} req - Express request object
* @param {Object} res - Express response object
* @param {function} next - Express next function
*/
exports.createSpcpRedirectURL = (authClients) => {
return (req, res, next) => {
const { target, authType, esrvcId } = req.query
let authClient = authClients[authType] ? authClients[authType] : undefined
if (target && authClient && esrvcId) {
req.redirectURL = authClient.createRedirectURL(target, esrvcId)
return next()
} else {
return res
.status(StatusCodes.BAD_REQUEST)
.json({ message: 'Redirect URL malformed' })
}
}
}

const getSubstringBetween = (text, markerStart, markerEnd) => {
const start = text.indexOf(markerStart)
if (start === -1) {
return null
} else {
const end = text.indexOf(markerEnd, start)
return end === -1 ? null : text.substring(start + markerStart.length, end)
}
}

exports.validateESrvcId = (req, res) => {
let { redirectURL } = req
let validateUrl = redirectURL

axios
.get(validateUrl, {
headers: {
Accept:
'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3',
},
timeout: 10000, // 10 seconds
// Throw error if not status 200.
validateStatus: (status) => status === StatusCodes.OK,
})
.then(({ data }) => {
// The successful login page should have the title 'SingPass Login'
// The error page should have the title 'SingPass - System Error Page'
const title = getSubstringBetween(data, '<title>', '</title>')
if (title === null) {
logger.error({
message: 'Could not find title',
meta: {
action: 'validateESrvcId',
...createReqMeta(req),
redirectUrl: redirectURL,
data,
},
})
return res.status(StatusCodes.BAD_GATEWAY).json({
message: 'Singpass returned incomprehensible content',
})
}
if (title.indexOf('Error') === -1) {
return res.status(StatusCodes.OK).json({
isValid: true,
})
}

// The error page should have text like 'System Code:&nbsp<b>138</b>'
const errorCode = getSubstringBetween(
data,
'System Code:&nbsp<b>',
'</b>',
)
return res.status(StatusCodes.OK).json({
isValid: false,
errorCode,
})
})
.catch((err) => {
const { statusCode } = err.response || {}
logger.error({
message: 'Could not contact singpass to validate eservice id',
meta: {
action: 'validateESrvcId',
...createReqMeta(req),
redirectUrl: redirectURL,
statusCode,
},
error: err,
})
return res.status(StatusCodes.SERVICE_UNAVAILABLE).json({
message: 'Failed to contact Singpass',
})
})
}

/**
* Assertion Consumer Endpoint - Authenticates form-filler with SingPass and creates session
* @param {Object} req - Express request object
Expand Down
5 changes: 0 additions & 5 deletions src/app/factories/spcp.factory.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,6 @@ const spcpFactory = ({ isEnabled, props }) => {
corpPassLogin: spcp.corpPassLogin(ndiConfig),
addSpcpSessionInfo: spcp.addSpcpSessionInfo(authClients),
isSpcpAuthenticated: spcp.isSpcpAuthenticated(authClients),
createSpcpRedirectURL: spcp.createSpcpRedirectURL(authClients),
validateESrvcId: spcp.validateESrvcId,
}
} else {
const errMsg = 'SPCP/MyInfo feature is not enabled'
Expand All @@ -85,9 +83,6 @@ const spcpFactory = ({ isEnabled, props }) => {
res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ message: errMsg }),
addSpcpSessionInfo: (req, res, next) => next(),
isSpcpAuthenticated: (req, res, next) => next(),
createSpcpRedirectURL: (req, res, next) => next(),
validateESrvcId: (req, res) =>
res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ message: errMsg }),
}
}
}
Expand Down
183 changes: 163 additions & 20 deletions src/app/modules/spcp/__tests__/spcp.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
import { err, ok } from 'neverthrow'
import { err, errAsync, ok, okAsync } from 'neverthrow'
import { mocked } from 'ts-jest/utils'

import { AuthType } from 'src/types'

import expressHandler from 'tests/unit/backend/helpers/jest-express'

import * as SpcpController from '../spcp.controller'
import { CreateRedirectUrlError } from '../spcp.errors'
import {
CreateRedirectUrlError,
FetchLoginPageError,
LoginPageValidationError,
} from '../spcp.errors'
import { SpcpFactory } from '../spcp.factory'

import {
MOCK_ERROR_CODE,
MOCK_ESRVCID,
MOCK_LOGIN_HTML,
MOCK_REDIRECT_URL,
MOCK_TARGET,
} from './spcp.test.constants'
Expand All @@ -19,49 +25,186 @@ jest.mock('../spcp.factory')
const MockSpcpFactory = mocked(SpcpFactory, true)

const MOCK_RESPONSE = expressHandler.mockResponse()
const MOCK_REDIRECT_REQ = expressHandler.mockRequest({
query: {
target: MOCK_TARGET,
authType: AuthType.SP,
esrvcId: MOCK_ESRVCID,
},
})

describe('spcp.controller', () => {
beforeEach(() => jest.clearAllMocks())

describe('handleRedirect', () => {
it('should return the redirect URL correctly', () => {
const mockReq = expressHandler.mockRequest({
query: {
target: MOCK_TARGET,
authType: AuthType.SP,
esrvcId: MOCK_ESRVCID,
},
})
MockSpcpFactory.createRedirectUrl.mockReturnValueOnce(
ok(MOCK_REDIRECT_URL),
)

SpcpController.handleRedirect(mockReq, MOCK_RESPONSE, jest.fn())
SpcpController.handleRedirect(MOCK_REDIRECT_REQ, MOCK_RESPONSE, jest.fn())

expect(MOCK_RESPONSE.status).toHaveBeenLastCalledWith(200)
expect(MockSpcpFactory.createRedirectUrl).toHaveBeenCalledWith(
AuthType.SP,
MOCK_TARGET,
MOCK_ESRVCID,
)
expect(MOCK_RESPONSE.status).toHaveBeenCalledWith(200)
expect(MOCK_RESPONSE.json).toHaveBeenCalledWith({
redirectURL: MOCK_REDIRECT_URL,
})
})

it('should return 500 if auth client throws an error', () => {
const mockReq = expressHandler.mockRequest({
query: {
target: MOCK_TARGET,
authType: AuthType.SP,
esrvcId: MOCK_ESRVCID,
},
})
MockSpcpFactory.createRedirectUrl.mockReturnValueOnce(
err(new CreateRedirectUrlError()),
)

SpcpController.handleRedirect(mockReq, MOCK_RESPONSE, jest.fn())
SpcpController.handleRedirect(MOCK_REDIRECT_REQ, MOCK_RESPONSE, jest.fn())

expect(MOCK_RESPONSE.status).toHaveBeenLastCalledWith(500)
expect(MockSpcpFactory.createRedirectUrl).toHaveBeenCalledWith(
AuthType.SP,
MOCK_TARGET,
MOCK_ESRVCID,
)
expect(MOCK_RESPONSE.status).toHaveBeenCalledWith(500)
expect(MOCK_RESPONSE.json).toHaveBeenCalledWith({
message: 'Sorry, something went wrong. Please try again.',
})
})
})

describe('handleValidate', () => {
it('should return 200 with isValid true if validation passes', async () => {
MockSpcpFactory.createRedirectUrl.mockReturnValueOnce(
ok(MOCK_REDIRECT_URL),
)
MockSpcpFactory.fetchLoginPage.mockReturnValueOnce(
okAsync(MOCK_LOGIN_HTML),
)
MockSpcpFactory.validateLoginPage.mockReturnValueOnce(
ok({ isValid: true }),
)

await SpcpController.handleValidate(
MOCK_REDIRECT_REQ,
MOCK_RESPONSE,
jest.fn(),
)

expect(MockSpcpFactory.createRedirectUrl).toHaveBeenCalledWith(
AuthType.SP,
MOCK_TARGET,
MOCK_ESRVCID,
)
expect(MockSpcpFactory.fetchLoginPage).toHaveBeenCalledWith(
MOCK_REDIRECT_URL,
)
expect(MockSpcpFactory.validateLoginPage).toHaveBeenCalledWith(
MOCK_LOGIN_HTML,
)
expect(MOCK_RESPONSE.status).toHaveBeenCalledWith(200)
expect(MOCK_RESPONSE.json).toHaveBeenCalledWith({
isValid: true,
})
})

it('should return 200 with isValid false if validation fails', async () => {
MockSpcpFactory.createRedirectUrl.mockReturnValueOnce(
ok(MOCK_REDIRECT_URL),
)
MockSpcpFactory.fetchLoginPage.mockReturnValueOnce(
okAsync(MOCK_LOGIN_HTML),
)
MockSpcpFactory.validateLoginPage.mockReturnValueOnce(
ok({ isValid: false, errorCode: MOCK_ERROR_CODE }),
)

await SpcpController.handleValidate(
MOCK_REDIRECT_REQ,
MOCK_RESPONSE,
jest.fn(),
)

expect(MockSpcpFactory.createRedirectUrl).toHaveBeenCalledWith(
AuthType.SP,
MOCK_TARGET,
MOCK_ESRVCID,
)
expect(MockSpcpFactory.fetchLoginPage).toHaveBeenCalledWith(
MOCK_REDIRECT_URL,
)
expect(MockSpcpFactory.validateLoginPage).toHaveBeenCalledWith(
MOCK_LOGIN_HTML,
)
expect(MOCK_RESPONSE.status).toHaveBeenCalledWith(200)
expect(MOCK_RESPONSE.json).toHaveBeenCalledWith({
isValid: false,
errorCode: MOCK_ERROR_CODE,
})
})

it('should return 503 when FetchLoginPageError occurs', async () => {
MockSpcpFactory.createRedirectUrl.mockReturnValueOnce(
ok(MOCK_REDIRECT_URL),
)
MockSpcpFactory.fetchLoginPage.mockReturnValueOnce(
errAsync(new FetchLoginPageError()),
)

await SpcpController.handleValidate(
MOCK_REDIRECT_REQ,
MOCK_RESPONSE,
jest.fn(),
)

expect(MockSpcpFactory.createRedirectUrl).toHaveBeenCalledWith(
AuthType.SP,
MOCK_TARGET,
MOCK_ESRVCID,
)
expect(MockSpcpFactory.fetchLoginPage).toHaveBeenCalledWith(
MOCK_REDIRECT_URL,
)
expect(MockSpcpFactory.validateLoginPage).not.toHaveBeenCalled()
expect(MOCK_RESPONSE.status).toHaveBeenCalledWith(503)
expect(MOCK_RESPONSE.json).toHaveBeenCalledWith({
message: 'Failed to contact SingPass. Please try again.',
})
})

it('should return 502 when LoginPageValidationError occurs', async () => {
MockSpcpFactory.createRedirectUrl.mockReturnValueOnce(
ok(MOCK_REDIRECT_URL),
)
MockSpcpFactory.fetchLoginPage.mockReturnValueOnce(
okAsync(MOCK_LOGIN_HTML),
)
MockSpcpFactory.validateLoginPage.mockReturnValueOnce(
err(new LoginPageValidationError()),
)

await SpcpController.handleValidate(
MOCK_REDIRECT_REQ,
MOCK_RESPONSE,
jest.fn(),
)

expect(MockSpcpFactory.createRedirectUrl).toHaveBeenCalledWith(
AuthType.SP,
MOCK_TARGET,
MOCK_ESRVCID,
)
expect(MockSpcpFactory.fetchLoginPage).toHaveBeenCalledWith(
MOCK_REDIRECT_URL,
)
expect(MockSpcpFactory.validateLoginPage).toHaveBeenCalledWith(
MOCK_LOGIN_HTML,
)
expect(MOCK_RESPONSE.status).toHaveBeenCalledWith(502)
expect(MOCK_RESPONSE.json).toHaveBeenCalledWith({
message: 'Error while contacting SingPass. Please try again.',
})
})
})
})
Loading